deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/ai-core/.eslintrc.js
Normal file
10
packages/ai-core/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
64
packages/ai-core/README.md
Normal file
64
packages/ai-core/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
<div align='center'>
|
||||
|
||||
<br />
|
||||
|
||||
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
|
||||
|
||||
<h2>ECLIPSE THEIA - AI CORE EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
The `@theia/ai-core` extension serves as the basis of all AI integration in Theia.
|
||||
It manages the integration of language models and provides core concepts like agents, prompts, AI variables, and skills.
|
||||
|
||||
### Skills
|
||||
|
||||
Skills provide reusable instructions and domain knowledge for AI agents. A skill is a directory containing a `SKILL.md` file with YAML frontmatter (name, description) and markdown content.
|
||||
|
||||
#### Skill Directories
|
||||
|
||||
Skills are discovered from multiple locations, processed in priority order (first wins on duplicates):
|
||||
|
||||
1. **Workspace:** `.prompts/skills/` in the workspace root (project-specific skills)
|
||||
2. **User-configured:** Directories listed in `ai-features.skills.skillDirectories` preference
|
||||
3. **Global:** `~/.theia/skills/` (user defaults)
|
||||
|
||||
#### Skill Structure
|
||||
|
||||
Each skill must be in its own directory with the directory name matching the skill name:
|
||||
|
||||
```text
|
||||
skills/
|
||||
├── my-skill/
|
||||
│ └── SKILL.md
|
||||
└── another-skill/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
- Add `{{skills}}` to an agent's prompt to inject available skills as XML (name and description)
|
||||
- Agents can read full skill content using the `getSkillFileContent` tool with the skill name
|
||||
|
||||
Enablement of the Theia AI feature is managed via the AI preferences, contributed by `@theia/ai-core-ui`.
|
||||
Either include `@theia/ai-core-ui` or bind the included preferences schemas in your Theia based application.
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/ai-core`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai-core.html)
|
||||
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
|
||||
- [Theia - Website](https://theia-ide.org/)
|
||||
|
||||
## License
|
||||
|
||||
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
|
||||
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
|
||||
|
||||
## Trademark
|
||||
|
||||
"Theia" is a trademark of the Eclipse Foundation
|
||||
<https://www.eclipse.org/theia>
|
||||
110
packages/ai-core/data/prompttemplate.tmLanguage.json
Normal file
110
packages/ai-core/data/prompttemplate.tmLanguage.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"scopeName": "source.prompttemplate",
|
||||
"patterns": [
|
||||
{
|
||||
"name": "invalid.illegal.mismatched.prompttemplate",
|
||||
"match": "\\{\\{\\{[^{}]*\\}\\}(?!\\})",
|
||||
"captures": {
|
||||
"0": {
|
||||
"name": "invalid.illegal.bracket.mismatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "invalid.illegal.mismatched.prompttemplate",
|
||||
"match": "\\{\\{[^{}]*\\}\\}\\}(?!\\})",
|
||||
"captures": {
|
||||
"0": {
|
||||
"name": "invalid.illegal.bracket.mismatch"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "comment.block.prompttemplate",
|
||||
"begin": "\\A{{!--",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.comment.begin"
|
||||
}
|
||||
},
|
||||
"end": "--}}",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.comment.end"
|
||||
}
|
||||
},
|
||||
"patterns": []
|
||||
},
|
||||
{
|
||||
"name": "variable.other.prompttemplate.double",
|
||||
"begin": "\\{\\{",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.variable.begin"
|
||||
}
|
||||
},
|
||||
"end": "\\}\\}(?!\\})",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.variable.end"
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.control",
|
||||
"match": "[a-zA-Z_][a-zA-Z0-9_]*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "variable.other.prompttemplate.triple",
|
||||
"begin": "\\{\\{\\{",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.variable.begin"
|
||||
}
|
||||
},
|
||||
"end": "\\}\\}\\}(?!\\})",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.variable.end"
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.control",
|
||||
"match": "[a-zA-Z_][a-zA-Z0-9_]*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "support.function.prompttemplate",
|
||||
"begin": "~{",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.brace.begin"
|
||||
}
|
||||
},
|
||||
"end": "}",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.brace.end"
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.control",
|
||||
"match": "[a-zA-Z_][a-zA-Z0-9_\\-]*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "text.html.markdown"
|
||||
}
|
||||
],
|
||||
"repository": {},
|
||||
"name": "PromptTemplate",
|
||||
"fileTypes": [
|
||||
".prompttemplate"
|
||||
]
|
||||
}
|
||||
61
packages/ai-core/package.json
Normal file
61
packages/ai-core/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@theia/ai-core",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - AI Core",
|
||||
"dependencies": {
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/editor": "1.68.0",
|
||||
"@theia/filesystem": "1.68.0",
|
||||
"@theia/monaco": "1.68.0",
|
||||
"@theia/monaco-editor-core": "1.96.302",
|
||||
"@theia/output": "1.68.0",
|
||||
"@theia/variable-resolver": "1.68.0",
|
||||
"@theia/workspace": "1.68.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"main": "lib/common",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/ai-core-frontend-module",
|
||||
"backend": "lib/node/ai-core-backend-module"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"theia-extension"
|
||||
],
|
||||
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/eclipse-theia/theia.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/eclipse-theia/theia/issues"
|
||||
},
|
||||
"homepage": "https://github.com/eclipse-theia/theia",
|
||||
"files": [
|
||||
"data",
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "theiaext build",
|
||||
"clean": "theiaext clean",
|
||||
"compile": "theiaext compile",
|
||||
"lint": "theiaext lint",
|
||||
"test": "theiaext test",
|
||||
"watch": "theiaext watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@theia/ext-scripts": "1.68.0"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "../../configs/nyc.json"
|
||||
},
|
||||
"gitHead": "21358137e41342742707f660b8e222f940a27652"
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||||
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import {
|
||||
PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE,
|
||||
} from '../common/ai-core-preferences';
|
||||
import { AgentService } from '../common/agent-service';
|
||||
import { AISettingsService } from '../common/settings-service';
|
||||
import { OSNotificationService } from './os-notification-service';
|
||||
import { WindowBlinkService } from './window-blink-service';
|
||||
import {
|
||||
NotificationType,
|
||||
NOTIFICATION_TYPE_OFF,
|
||||
NOTIFICATION_TYPE_OS_NOTIFICATION,
|
||||
NOTIFICATION_TYPE_MESSAGE,
|
||||
NOTIFICATION_TYPE_BLINK,
|
||||
} from '../common/notification-types';
|
||||
import { PreferenceService } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class AgentCompletionNotificationService {
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(AgentService)
|
||||
protected readonly agentService: AgentService;
|
||||
|
||||
@inject(AISettingsService)
|
||||
protected readonly settingsService: AISettingsService;
|
||||
|
||||
@inject(OSNotificationService)
|
||||
protected readonly osNotificationService: OSNotificationService;
|
||||
|
||||
@inject(MessageService)
|
||||
protected readonly messageService: MessageService;
|
||||
|
||||
@inject(WindowBlinkService)
|
||||
protected readonly windowBlinkService: WindowBlinkService;
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
/**
|
||||
* Show a completion notification for the specified agent if enabled in preferences.
|
||||
*
|
||||
* @param agentId The unique identifier of the agent
|
||||
* @param taskDescription Optional description of the completed task
|
||||
*/
|
||||
async showCompletionNotification(
|
||||
agentId: string,
|
||||
taskDescription?: string,
|
||||
): Promise<void> {
|
||||
const notificationType =
|
||||
await this.getNotificationTypeForAgent(agentId);
|
||||
|
||||
if (notificationType === NOTIFICATION_TYPE_OFF || this.isChatWidgetFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentName = this.resolveAgentName(agentId);
|
||||
await this.executeNotificationType(
|
||||
agentName,
|
||||
taskDescription,
|
||||
notificationType,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to show agent completion notification:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display name for an agent by its ID.
|
||||
*
|
||||
* @param agentId The unique identifier of the agent
|
||||
* @returns The agent's display name or the agent ID if not found
|
||||
*/
|
||||
protected resolveAgentName(agentId: string): string {
|
||||
try {
|
||||
const agents = this.agentService.getAllAgents();
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
return agent?.name || agentId;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to resolve agent name for ID '${agentId}':`,
|
||||
error,
|
||||
);
|
||||
return agentId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred notification type for a specific agent.
|
||||
* If no agent-specific preference is set, returns the global default notification type.
|
||||
*/
|
||||
protected async getNotificationTypeForAgent(
|
||||
agentId: string,
|
||||
): Promise<NotificationType> {
|
||||
const agentSettings =
|
||||
await this.settingsService.getAgentSettings(agentId);
|
||||
const agentNotificationType = agentSettings?.completionNotification as NotificationType;
|
||||
|
||||
// If agent has no specific setting, use the global default
|
||||
if (!agentNotificationType) {
|
||||
return this.preferenceService.get<NotificationType>(
|
||||
PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE,
|
||||
NOTIFICATION_TYPE_OFF,
|
||||
);
|
||||
}
|
||||
|
||||
return agentNotificationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the specified notification type.
|
||||
*/
|
||||
private async executeNotificationType(
|
||||
agentName: string,
|
||||
taskDescription: string | undefined,
|
||||
type: NotificationType,
|
||||
): Promise<void> {
|
||||
switch (type) {
|
||||
case NOTIFICATION_TYPE_OS_NOTIFICATION:
|
||||
await this.showOSNotification(agentName, taskDescription);
|
||||
break;
|
||||
case NOTIFICATION_TYPE_MESSAGE:
|
||||
await this.showMessageServiceNotification(
|
||||
agentName,
|
||||
taskDescription,
|
||||
);
|
||||
break;
|
||||
case NOTIFICATION_TYPE_BLINK:
|
||||
await this.showBlinkNotification(agentName);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown notification type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show OS notification directly.
|
||||
*/
|
||||
protected async showOSNotification(
|
||||
agentName: string,
|
||||
taskDescription?: string,
|
||||
): Promise<void> {
|
||||
const result =
|
||||
await this.osNotificationService.showAgentCompletionNotification(
|
||||
agentName,
|
||||
taskDescription,
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(`OS notification failed: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show MessageService notification.
|
||||
*/
|
||||
protected async showMessageServiceNotification(
|
||||
agentName: string,
|
||||
taskDescription?: string,
|
||||
): Promise<void> {
|
||||
const message = taskDescription
|
||||
? nls.localize(
|
||||
'theia/ai-core/agentCompletionWithTask',
|
||||
'Agent "{0}" has completed the task: {1}',
|
||||
agentName,
|
||||
taskDescription,
|
||||
)
|
||||
: nls.localize(
|
||||
'theia/ai-core/agentCompletionMessage',
|
||||
'Agent "{0}" has completed its task.',
|
||||
agentName,
|
||||
);
|
||||
this.messageService.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show window blink notification.
|
||||
*/
|
||||
protected async showBlinkNotification(agentName: string): Promise<void> {
|
||||
const result = await this.windowBlinkService.blinkWindow(agentName);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Window blink notification failed: ${result.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OS notifications are supported and enabled.
|
||||
*/
|
||||
isOSNotificationSupported(): boolean {
|
||||
return this.osNotificationService.isNotificationSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current OS notification permission status.
|
||||
*/
|
||||
getOSNotificationPermission(): NotificationPermission {
|
||||
return this.osNotificationService.getPermissionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request OS notification permission from the user.
|
||||
*/
|
||||
async requestOSNotificationPermission(): Promise<NotificationPermission> {
|
||||
return this.osNotificationService.requestPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any chat widget currently has focus.
|
||||
*/
|
||||
protected isChatWidgetFocused(): boolean {
|
||||
const activeWidget = this.shell.activeWidget;
|
||||
if (!activeWidget) {
|
||||
return false;
|
||||
}
|
||||
return activeWidget.id === 'chat-view-widget';
|
||||
}
|
||||
}
|
||||
59
packages/ai-core/src/browser/ai-activation-service.ts
Normal file
59
packages/ai-core/src/browser/ai-activation-service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
|
||||
export const AIActivationService = Symbol('AIActivationService');
|
||||
/**
|
||||
* AIActivationService is used to manage the activation state of AI features in Theia.
|
||||
*/
|
||||
export interface AIActivationService {
|
||||
isActive: boolean;
|
||||
onDidChangeActiveStatus: Event<boolean>;
|
||||
}
|
||||
import { Emitter, Event } from '@theia/core';
|
||||
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
|
||||
|
||||
/**
|
||||
* Context key for the AI features. It is set to `true` if the feature is enabled.
|
||||
*/
|
||||
export const ENABLE_AI_CONTEXT_KEY = 'ai-features.AiEnable.enableAI';
|
||||
|
||||
/**
|
||||
* Default implementation of AIActivationService marks the feature active by default.
|
||||
*
|
||||
* Adopters may override this implementation to provide custom activation logic.
|
||||
*
|
||||
* Note that '@theia/ai-ide' also overrides this service to provide activation based on preferences,
|
||||
* disabling the feature by default.
|
||||
*/
|
||||
@injectable()
|
||||
export class AIActivationServiceImpl implements AIActivationService, FrontendApplicationContribution {
|
||||
|
||||
@inject(ContextKeyService)
|
||||
protected readonly contextKeyService: ContextKeyService;
|
||||
|
||||
isActive: boolean = true;
|
||||
|
||||
protected onDidChangeAIEnabled = new Emitter<boolean>();
|
||||
get onDidChangeActiveStatus(): Event<boolean> {
|
||||
return this.onDidChangeAIEnabled.event;
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.contextKeyService.createKey(ENABLE_AI_CONTEXT_KEY, true);
|
||||
}
|
||||
}
|
||||
20
packages/ai-core/src/browser/ai-command-handler-factory.ts
Normal file
20
packages/ai-core/src/browser/ai-command-handler-factory.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { CommandHandler } from '@theia/core';
|
||||
|
||||
export type AICommandHandlerFactory = (handler: CommandHandler) => CommandHandler;
|
||||
export const AICommandHandlerFactory = Symbol('AICommandHandlerFactory');
|
||||
37
packages/ai-core/src/browser/ai-core-command-contribution.ts
Normal file
37
packages/ai-core/src/browser/ai-core-command-contribution.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Command, CommandContribution, CommandRegistry } from '@theia/core';
|
||||
import { CommonCommands, codicon } from '@theia/core/lib/browser';
|
||||
import { AICommandHandlerFactory } from './ai-command-handler-factory';
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
|
||||
export const AI_SHOW_SETTINGS_COMMAND: Command = Command.toLocalizedCommand({
|
||||
id: 'ai-chat-ui.show-settings',
|
||||
label: 'Show AI Settings',
|
||||
iconClass: codicon('settings-gear'),
|
||||
});
|
||||
|
||||
@injectable()
|
||||
export class AiCoreCommandContribution implements CommandContribution {
|
||||
@inject(AICommandHandlerFactory) protected readonly handlerFactory: AICommandHandlerFactory;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(AI_SHOW_SETTINGS_COMMAND, this.handlerFactory({
|
||||
execute: () => commands.executeCommand(CommonCommands.OPEN_PREFERENCES.id, 'ai-features'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import { Agent } from '../common';
|
||||
import { AgentService } from '../common/agent-service';
|
||||
import { ContributionProvider } from '@theia/core/lib/common/contribution-provider';
|
||||
|
||||
@injectable()
|
||||
export class AICoreFrontendApplicationContribution implements FrontendApplicationContribution {
|
||||
@inject(AgentService)
|
||||
private readonly agentService: AgentService;
|
||||
|
||||
@inject(ContributionProvider) @named(Agent)
|
||||
protected readonly agentsProvider: ContributionProvider<Agent>;
|
||||
|
||||
onStart(): void {
|
||||
this.agentsProvider.getContributions().forEach(agent => {
|
||||
this.agentService.registerAgent(agent);
|
||||
});
|
||||
}
|
||||
|
||||
onStop(): void {
|
||||
}
|
||||
}
|
||||
198
packages/ai-core/src/browser/ai-core-frontend-module.ts
Normal file
198
packages/ai-core/src/browser/ai-core-frontend-module.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { bindContributionProvider, CommandContribution, CommandHandler, ResourceResolver } from '@theia/core';
|
||||
import {
|
||||
RemoteConnectionProvider,
|
||||
ServiceConnectionProvider,
|
||||
} from '@theia/core/lib/browser/messaging/service-connection-provider';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { DefaultLanguageModelAliasRegistry } from './frontend-language-model-alias-registry';
|
||||
import { LanguageModelAliasRegistry } from '../common/language-model-alias';
|
||||
import {
|
||||
AIVariableContribution,
|
||||
AIVariableService,
|
||||
ToolInvocationRegistry,
|
||||
ToolInvocationRegistryImpl,
|
||||
LanguageModelDelegateClient,
|
||||
languageModelDelegatePath,
|
||||
LanguageModelFrontendDelegate,
|
||||
LanguageModelProvider,
|
||||
LanguageModelRegistry,
|
||||
LanguageModelRegistryClient,
|
||||
languageModelRegistryDelegatePath,
|
||||
LanguageModelRegistryFrontendDelegate,
|
||||
PromptFragmentCustomizationService,
|
||||
PromptService,
|
||||
PromptServiceImpl,
|
||||
ToolProvider,
|
||||
TokenUsageService,
|
||||
TOKEN_USAGE_SERVICE_PATH,
|
||||
TokenUsageServiceClient,
|
||||
AIVariableResourceResolver,
|
||||
ConfigurableInMemoryResources,
|
||||
Agent,
|
||||
FrontendLanguageModelRegistry
|
||||
} from '../common';
|
||||
import {
|
||||
FrontendLanguageModelRegistryImpl,
|
||||
LanguageModelDelegateClientImpl,
|
||||
} from './frontend-language-model-registry';
|
||||
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
|
||||
import { LanguageGrammarDefinitionContribution } from '@theia/monaco/lib/browser/textmate';
|
||||
import { AICoreFrontendApplicationContribution } from './ai-core-frontend-application-contribution';
|
||||
import { bindAICorePreferences } from '../common/ai-core-preferences';
|
||||
import { AISettingsServiceImpl } from './ai-settings-service';
|
||||
import { DefaultPromptFragmentCustomizationService } from './frontend-prompt-customization-service';
|
||||
import { DefaultFrontendVariableService, FrontendVariableService } from './frontend-variable-service';
|
||||
import { PromptTemplateContribution } from './prompttemplate-contribution';
|
||||
import { FileVariableContribution } from './file-variable-contribution';
|
||||
import { TheiaVariableContribution } from './theia-variable-contribution';
|
||||
import { TodayVariableContribution } from '../common/today-variable-contribution';
|
||||
import { AgentsVariableContribution } from '../common/agents-variable-contribution';
|
||||
import { OpenEditorsVariableContribution } from './open-editors-variable-contribution';
|
||||
import { SkillsVariableContribution } from './skills-variable-contribution';
|
||||
import { AIActivationService, AIActivationServiceImpl } from './ai-activation-service';
|
||||
import { AgentService, AgentServiceImpl } from '../common/agent-service';
|
||||
import { AICommandHandlerFactory } from './ai-command-handler-factory';
|
||||
import { AISettingsService } from '../common/settings-service';
|
||||
import { DefaultSkillService, SkillService } from './skill-service';
|
||||
import { SkillPromptCoordinator } from './skill-prompt-coordinator';
|
||||
import { AiCoreCommandContribution } from './ai-core-command-contribution';
|
||||
import { PromptVariableContribution } from '../common/prompt-variable-contribution';
|
||||
import { LanguageModelService } from '../common/language-model-service';
|
||||
import { FrontendLanguageModelServiceImpl } from './frontend-language-model-service';
|
||||
import { TokenUsageFrontendService } from './token-usage-frontend-service';
|
||||
import { TokenUsageFrontendServiceImpl, TokenUsageServiceClientImpl } from './token-usage-frontend-service-impl';
|
||||
import { AIVariableUriLabelProvider } from './ai-variable-uri-label-provider';
|
||||
import { AgentCompletionNotificationService } from './agent-completion-notification-service';
|
||||
import { OSNotificationService } from './os-notification-service';
|
||||
import { WindowBlinkService } from './window-blink-service';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bindContributionProvider(bind, Agent);
|
||||
bindContributionProvider(bind, LanguageModelProvider);
|
||||
|
||||
bind(FrontendLanguageModelRegistryImpl).toSelf().inSingletonScope();
|
||||
bind(FrontendLanguageModelRegistry).toService(FrontendLanguageModelRegistryImpl);
|
||||
bind(LanguageModelRegistry).toService(FrontendLanguageModelRegistryImpl);
|
||||
|
||||
bind(LanguageModelDelegateClientImpl).toSelf().inSingletonScope();
|
||||
bind(LanguageModelDelegateClient).toService(LanguageModelDelegateClientImpl);
|
||||
bind(LanguageModelRegistryClient).toService(LanguageModelDelegateClient);
|
||||
|
||||
bind(LanguageModelRegistryFrontendDelegate).toDynamicValue(
|
||||
ctx => {
|
||||
const connection = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
const client = ctx.container.get<LanguageModelRegistryClient>(LanguageModelRegistryClient);
|
||||
return connection.createProxy<LanguageModelRegistryFrontendDelegate>(languageModelRegistryDelegatePath, client);
|
||||
}
|
||||
);
|
||||
|
||||
bind(LanguageModelFrontendDelegate)
|
||||
.toDynamicValue(ctx => {
|
||||
const connection = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
const client = ctx.container.get<LanguageModelDelegateClient>(LanguageModelDelegateClient);
|
||||
return connection.createProxy<LanguageModelFrontendDelegate>(languageModelDelegatePath, client);
|
||||
})
|
||||
.inSingletonScope();
|
||||
|
||||
bindAICorePreferences(bind);
|
||||
|
||||
bind(DefaultPromptFragmentCustomizationService).toSelf().inSingletonScope();
|
||||
bind(PromptFragmentCustomizationService).toService(DefaultPromptFragmentCustomizationService);
|
||||
bind(PromptServiceImpl).toSelf().inSingletonScope();
|
||||
bind(PromptService).toService(PromptServiceImpl);
|
||||
|
||||
bind(PromptTemplateContribution).toSelf().inSingletonScope();
|
||||
bind(LanguageGrammarDefinitionContribution).toService(PromptTemplateContribution);
|
||||
bind(CommandContribution).toService(PromptTemplateContribution);
|
||||
bind(TabBarToolbarContribution).toService(PromptTemplateContribution);
|
||||
|
||||
bind(AISettingsServiceImpl).toSelf().inSingletonScope();
|
||||
bind(AISettingsService).toService(AISettingsServiceImpl);
|
||||
|
||||
bind(DefaultSkillService).toSelf().inSingletonScope();
|
||||
bind(SkillService).toService(DefaultSkillService);
|
||||
|
||||
bind(SkillPromptCoordinator).toSelf().inSingletonScope();
|
||||
bind(FrontendApplicationContribution).toService(SkillPromptCoordinator);
|
||||
bindContributionProvider(bind, AIVariableContribution);
|
||||
bind(DefaultFrontendVariableService).toSelf().inSingletonScope();
|
||||
bind(FrontendVariableService).toService(DefaultFrontendVariableService);
|
||||
bind(AIVariableService).toService(FrontendVariableService);
|
||||
bind(FrontendApplicationContribution).toService(FrontendVariableService);
|
||||
|
||||
bind(TheiaVariableContribution).toSelf().inSingletonScope();
|
||||
bind(AIVariableContribution).toService(TheiaVariableContribution);
|
||||
|
||||
bind(AIVariableContribution).to(PromptVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(TodayVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(FileVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(AgentsVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(OpenEditorsVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(SkillsVariableContribution).inSingletonScope();
|
||||
|
||||
bind(FrontendApplicationContribution).to(AICoreFrontendApplicationContribution).inSingletonScope();
|
||||
|
||||
bind(ToolInvocationRegistry).to(ToolInvocationRegistryImpl).inSingletonScope();
|
||||
bindContributionProvider(bind, ToolProvider);
|
||||
|
||||
bind(AIActivationServiceImpl).toSelf().inSingletonScope();
|
||||
bind(AIActivationService).toService(AIActivationServiceImpl);
|
||||
bind(FrontendApplicationContribution).toService(AIActivationService);
|
||||
|
||||
bind(AgentServiceImpl).toSelf().inSingletonScope();
|
||||
bind(AgentService).toService(AgentServiceImpl);
|
||||
|
||||
bind(AICommandHandlerFactory).toFactory<CommandHandler>(context => (handler: CommandHandler) => {
|
||||
const activationService = context.container.get<AIActivationService>(AIActivationService);
|
||||
return {
|
||||
execute: (...args: unknown[]) => handler.execute(...args),
|
||||
isEnabled: (...args: unknown[]) => activationService.isActive && (handler.isEnabled?.(...args) ?? true),
|
||||
isVisible: (...args: unknown[]) => activationService.isActive && (handler.isVisible?.(...args) ?? true),
|
||||
isToggled: handler.isToggled
|
||||
};
|
||||
});
|
||||
|
||||
bind(AiCoreCommandContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(AiCoreCommandContribution);
|
||||
bind(FrontendLanguageModelServiceImpl).toSelf().inSingletonScope();
|
||||
bind(LanguageModelService).toService(FrontendLanguageModelServiceImpl);
|
||||
|
||||
bind(TokenUsageFrontendService).to(TokenUsageFrontendServiceImpl).inSingletonScope();
|
||||
bind(TokenUsageServiceClient).to(TokenUsageServiceClientImpl).inSingletonScope();
|
||||
|
||||
bind(DefaultLanguageModelAliasRegistry).toSelf().inSingletonScope();
|
||||
bind(LanguageModelAliasRegistry).toService(DefaultLanguageModelAliasRegistry);
|
||||
|
||||
bind(TokenUsageService).toDynamicValue(ctx => {
|
||||
const connection = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
const client = ctx.container.get<TokenUsageServiceClient>(TokenUsageServiceClient);
|
||||
return connection.createProxy<TokenUsageService>(TOKEN_USAGE_SERVICE_PATH, client);
|
||||
}).inSingletonScope();
|
||||
bind(AIVariableResourceResolver).toSelf().inSingletonScope();
|
||||
bind(ResourceResolver).toService(AIVariableResourceResolver);
|
||||
bind(AIVariableUriLabelProvider).toSelf().inSingletonScope();
|
||||
bind(LabelProviderContribution).toService(AIVariableUriLabelProvider);
|
||||
|
||||
bind(AgentCompletionNotificationService).toSelf().inSingletonScope();
|
||||
bind(OSNotificationService).toSelf().inSingletonScope();
|
||||
bind(WindowBlinkService).toSelf().inSingletonScope();
|
||||
bind(ConfigurableInMemoryResources).toSelf().inSingletonScope();
|
||||
bind(ResourceResolver).toService(ConfigurableInMemoryResources);
|
||||
});
|
||||
66
packages/ai-core/src/browser/ai-settings-service.ts
Normal file
66
packages/ai-core/src/browser/ai-settings-service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { DisposableCollection, Emitter, Event, ILogger, RecursiveReadonly } from '@theia/core';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { AISettings, AISettingsService, AgentSettings } from '../common';
|
||||
|
||||
@injectable()
|
||||
export class AISettingsServiceImpl implements AISettingsService {
|
||||
|
||||
@inject(ILogger)
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(PreferenceService) protected preferenceService: PreferenceService;
|
||||
static readonly PREFERENCE_NAME = 'ai-features.agentSettings';
|
||||
|
||||
protected toDispose = new DisposableCollection();
|
||||
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
onDidChange: Event<void> = this.onDidChangeEmitter.event;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.toDispose.push(
|
||||
this.preferenceService.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === AISettingsServiceImpl.PREFERENCE_NAME) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async updateAgentSettings(agent: string, agentSettings: Partial<AgentSettings>): Promise<void> {
|
||||
const settings = await this.getSettings();
|
||||
const toSet = { ...settings, [agent]: { ...settings[agent], ...agentSettings } };
|
||||
try {
|
||||
await this.preferenceService.updateValue(AISettingsServiceImpl.PREFERENCE_NAME, toSet);
|
||||
} catch (e) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
this.logger.warn('Updating the preferences was unsuccessful: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async getAgentSettings(agent: string): Promise<RecursiveReadonly<AgentSettings> | undefined> {
|
||||
const settings = await this.getSettings();
|
||||
return settings[agent];
|
||||
}
|
||||
|
||||
async getSettings(): Promise<RecursiveReadonly<AISettings>> {
|
||||
await this.preferenceService.ready;
|
||||
return this.preferenceService.get<AISettings>(AISettingsServiceImpl.PREFERENCE_NAME, {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 Eclipse GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { URI } from '@theia/core';
|
||||
import { LabelProvider, LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { AI_VARIABLE_RESOURCE_SCHEME, AIVariableResourceResolver } from '../common/ai-variable-resource';
|
||||
import { AIVariableResolutionRequest, AIVariableService } from '../common/variable-service';
|
||||
|
||||
@injectable()
|
||||
export class AIVariableUriLabelProvider implements LabelProviderContribution {
|
||||
|
||||
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
|
||||
@inject(AIVariableResourceResolver) protected variableResourceResolver: AIVariableResourceResolver;
|
||||
@inject(AIVariableService) protected readonly variableService: AIVariableService;
|
||||
|
||||
protected isMine(element: object): element is URI {
|
||||
return element instanceof URI && element.scheme === AI_VARIABLE_RESOURCE_SCHEME;
|
||||
}
|
||||
|
||||
canHandle(element: object): number {
|
||||
return this.isMine(element) ? 150 : -1;
|
||||
}
|
||||
|
||||
getIcon(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return this.labelProvider.getIcon(this.getResolutionRequest(element)!);
|
||||
}
|
||||
|
||||
getName(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return this.labelProvider.getName(this.getResolutionRequest(element)!);
|
||||
}
|
||||
|
||||
getLongName(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return this.labelProvider.getLongName(this.getResolutionRequest(element)!);
|
||||
}
|
||||
|
||||
getDetails(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return this.labelProvider.getDetails(this.getResolutionRequest(element)!);
|
||||
}
|
||||
|
||||
protected getResolutionRequest(element: object): AIVariableResolutionRequest | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
const metadata = this.variableResourceResolver.fromUri(element);
|
||||
if (!metadata) { return undefined; }
|
||||
const { variableName, arg } = metadata;
|
||||
const variable = this.variableService.getVariable(variableName);
|
||||
return variable && { variable, arg };
|
||||
}
|
||||
}
|
||||
77
packages/ai-core/src/browser/ai-view-contribution.ts
Normal file
77
packages/ai-core/src/browser/ai-view-contribution.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { CommandRegistry, MenuModelRegistry, PreferenceService } from '@theia/core';
|
||||
import { AbstractViewContribution, CommonMenus, KeybindingRegistry, Widget } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { AIActivationService, ENABLE_AI_CONTEXT_KEY } from './ai-activation-service';
|
||||
import { AICommandHandlerFactory } from './ai-command-handler-factory';
|
||||
|
||||
@injectable()
|
||||
export class AIViewContribution<T extends Widget> extends AbstractViewContribution<T> {
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(AIActivationService)
|
||||
protected readonly activationService: AIActivationService;
|
||||
|
||||
@inject(AICommandHandlerFactory)
|
||||
protected readonly commandHandlerFactory: AICommandHandlerFactory;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.activationService.onDidChangeActiveStatus(active => {
|
||||
if (!active) {
|
||||
this.closeView();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override registerCommands(commands: CommandRegistry): void {
|
||||
if (this.toggleCommand) {
|
||||
|
||||
commands.registerCommand(this.toggleCommand, this.commandHandlerFactory({
|
||||
execute: () => this.toggleView(),
|
||||
}));
|
||||
}
|
||||
this.quickView?.registerItem({
|
||||
label: this.viewLabel,
|
||||
when: ENABLE_AI_CONTEXT_KEY,
|
||||
open: () => this.openView({ activate: true })
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
override registerMenus(menus: MenuModelRegistry): void {
|
||||
if (this.toggleCommand) {
|
||||
menus.registerMenuAction(CommonMenus.VIEW_VIEWS, {
|
||||
commandId: this.toggleCommand.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY,
|
||||
label: this.viewLabel
|
||||
});
|
||||
}
|
||||
}
|
||||
override registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
if (this.toggleCommand && this.options.toggleKeybinding) {
|
||||
keybindings.registerKeybinding({
|
||||
command: this.toggleCommand.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY,
|
||||
keybinding: this.options.toggleKeybinding
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
122
packages/ai-core/src/browser/file-variable-contribution.ts
Normal file
122
packages/ai-core/src/browser/file-variable-contribution.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { nls, Path, URI } from '@theia/core';
|
||||
import { OpenerService, codiconArray, open } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
||||
import {
|
||||
AIVariable,
|
||||
AIVariableContext,
|
||||
AIVariableContribution,
|
||||
AIVariableOpener,
|
||||
AIVariableResolutionRequest,
|
||||
AIVariableResolver,
|
||||
ResolvedAIContextVariable,
|
||||
} from '../common/variable-service';
|
||||
import { FrontendVariableService } from './frontend-variable-service';
|
||||
|
||||
export namespace FileVariableArgs {
|
||||
export const uri = 'uri';
|
||||
}
|
||||
|
||||
export const FILE_VARIABLE: AIVariable = {
|
||||
id: 'file-provider',
|
||||
description: nls.localize('theia/ai/core/fileVariable/description', 'Resolves the contents of a file'),
|
||||
name: 'file',
|
||||
label: nls.localizeByDefault('File'),
|
||||
iconClasses: codiconArray('file'),
|
||||
isContextVariable: true,
|
||||
args: [{ name: FileVariableArgs.uri, description: nls.localize('theia/ai/core/fileVariable/uri/description', 'The URI of the requested file.') }]
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class FileVariableContribution implements AIVariableContribution, AIVariableResolver, AIVariableOpener {
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly wsService: WorkspaceService;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
|
||||
registerVariables(service: FrontendVariableService): void {
|
||||
service.registerResolver(FILE_VARIABLE, this);
|
||||
service.registerOpener(FILE_VARIABLE, this);
|
||||
}
|
||||
|
||||
async canResolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise<number> {
|
||||
return request.variable.name === FILE_VARIABLE.name ? 1 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise<ResolvedAIContextVariable | undefined> {
|
||||
const uri = await this.toUri(request);
|
||||
|
||||
if (!uri) { return undefined; }
|
||||
|
||||
try {
|
||||
const content = await this.fileService.readFile(uri);
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: await this.wsService.getWorkspaceRelativePath(uri),
|
||||
contextValue: content.value.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected async toUri(request: AIVariableResolutionRequest): Promise<URI | undefined> {
|
||||
if (request.variable.name !== FILE_VARIABLE.name || request.arg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path = request.arg;
|
||||
return this.makeAbsolute(path);
|
||||
}
|
||||
|
||||
canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<number> {
|
||||
return this.canResolve(request, context);
|
||||
}
|
||||
|
||||
async open(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<void> {
|
||||
const uri = await this.toUri(request);
|
||||
if (!uri) {
|
||||
throw new Error('Unable to resolve URI for request.');
|
||||
}
|
||||
await open(this.openerService, uri);
|
||||
}
|
||||
|
||||
protected async makeAbsolute(pathStr: string): Promise<URI | undefined> {
|
||||
const path = new Path(Path.normalizePathSeparator(pathStr));
|
||||
if (!path.isAbsolute) {
|
||||
const workspaceRoots = this.wsService.tryGetRoots();
|
||||
const wsUris = workspaceRoots.map(root => root.resource.resolve(path));
|
||||
for (const uri of wsUris) {
|
||||
if (await this.fileService.exists(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
const argUri = new URI(pathStr);
|
||||
if (await this.fileService.exists(argUri)) {
|
||||
return argUri;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024-2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Emitter, Event, nls } from '@theia/core';
|
||||
import { LanguageModelAlias, LanguageModelAliasRegistry } from '../common/language-model-alias';
|
||||
import { PreferenceScope, PreferenceService } from '@theia/core/lib/common';
|
||||
import { LANGUAGE_MODEL_ALIASES_PREFERENCE } from '../common/ai-core-preferences';
|
||||
import { Deferred } from '@theia/core/lib/common/promise-util';
|
||||
|
||||
@injectable()
|
||||
export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegistry {
|
||||
|
||||
protected aliases: LanguageModelAlias[] = [
|
||||
{
|
||||
id: 'default/code',
|
||||
defaultModelIds: [
|
||||
'anthropic/claude-opus-4-5',
|
||||
'openai/gpt-5.2',
|
||||
'google/gemini-3-pro-preview'
|
||||
],
|
||||
description: nls.localize('theia/ai/core/defaultModelAliases/code/description', 'Optimized for code understanding and generation tasks.')
|
||||
},
|
||||
{
|
||||
id: 'default/universal',
|
||||
defaultModelIds: [
|
||||
'openai/gpt-5.2',
|
||||
'anthropic/claude-opus-4-5',
|
||||
'google/gemini-3-pro-preview'
|
||||
],
|
||||
description: nls.localize('theia/ai/core/defaultModelAliases/universal/description', 'Well-balanced for both code and general language use.')
|
||||
},
|
||||
{
|
||||
id: 'default/code-completion',
|
||||
defaultModelIds: [
|
||||
'openai/gpt-4.1',
|
||||
'anthropic/claude-opus-4-5',
|
||||
'google/gemini-3-pro-preview'
|
||||
],
|
||||
description: nls.localize('theia/ai/core/defaultModelAliases/code-completion/description', 'Best suited for code autocompletion scenarios.')
|
||||
},
|
||||
{
|
||||
id: 'default/summarize',
|
||||
defaultModelIds: [
|
||||
'openai/gpt-5.2',
|
||||
'anthropic/claude-opus-4-5',
|
||||
'google/gemini-3-pro-preview'
|
||||
],
|
||||
description: nls.localize('theia/ai/core/defaultModelAliases/summarize/description', 'Models prioritized for summarization and condensation of content.')
|
||||
}
|
||||
];
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
protected readonly _ready = new Deferred<void>();
|
||||
get ready(): Promise<void> {
|
||||
return this._ready.promise;
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.preferenceService.ready.then(() => {
|
||||
this.loadFromPreference();
|
||||
this.preferenceService.onPreferenceChanged(ev => {
|
||||
if (ev.preferenceName === LANGUAGE_MODEL_ALIASES_PREFERENCE) {
|
||||
this.loadFromPreference();
|
||||
}
|
||||
});
|
||||
this._ready.resolve();
|
||||
}, err => {
|
||||
this._ready.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
addAlias(alias: LanguageModelAlias): void {
|
||||
const idx = this.aliases.findIndex(a => a.id === alias.id);
|
||||
if (idx !== -1) {
|
||||
this.aliases[idx] = alias;
|
||||
} else {
|
||||
this.aliases.push(alias);
|
||||
}
|
||||
this.saveToPreference();
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
|
||||
removeAlias(id: string): void {
|
||||
const idx = this.aliases.findIndex(a => a.id === id);
|
||||
if (idx !== -1) {
|
||||
this.aliases.splice(idx, 1);
|
||||
this.saveToPreference();
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
getAliases(): LanguageModelAlias[] {
|
||||
return [...this.aliases];
|
||||
}
|
||||
|
||||
resolveAlias(id: string): string[] | undefined {
|
||||
const alias = this.aliases.find(a => a.id === id);
|
||||
if (!alias) {
|
||||
return undefined;
|
||||
}
|
||||
if (alias.selectedModelId) {
|
||||
return [alias.selectedModelId];
|
||||
}
|
||||
return alias.defaultModelIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected model for the given alias id.
|
||||
* Updates the alias' selectedModelId to the given modelId, persists, and fires onDidChange.
|
||||
*/
|
||||
selectModelForAlias(aliasId: string, modelId: string): void {
|
||||
const alias = this.aliases.find(a => a.id === aliasId);
|
||||
if (alias) {
|
||||
alias.selectedModelId = modelId;
|
||||
this.saveToPreference();
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load aliases from the persisted setting
|
||||
*/
|
||||
protected loadFromPreference(): void {
|
||||
const stored = this.preferenceService.get<{ [name: string]: { selectedModel: string } }>(LANGUAGE_MODEL_ALIASES_PREFERENCE) || {};
|
||||
this.aliases.forEach(alias => {
|
||||
if (stored[alias.id] && stored[alias.id].selectedModel) {
|
||||
alias.selectedModelId = stored[alias.id].selectedModel;
|
||||
} else {
|
||||
delete alias.selectedModelId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current aliases and their selected models to the setting
|
||||
*/
|
||||
protected saveToPreference(): void {
|
||||
const map: { [name: string]: { selectedModel: string } } = {};
|
||||
for (const alias of this.aliases) {
|
||||
if (alias.selectedModelId) {
|
||||
map[alias.id] = { selectedModel: alias.selectedModelId };
|
||||
}
|
||||
}
|
||||
this.preferenceService.set(LANGUAGE_MODEL_ALIASES_PREFERENCE, map, PreferenceScope.User);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
createToolCallError,
|
||||
hasToolCallError,
|
||||
hasToolNotAvailableError,
|
||||
isToolCallContent,
|
||||
LanguageModelRequest,
|
||||
ToolCallContent,
|
||||
ToolCallErrorResult,
|
||||
ToolRequest
|
||||
} from '../common';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
function getFirstErrorMessage(result: ToolCallContent): string | undefined {
|
||||
const errorItem = result.content.find((item): item is ToolCallErrorResult => item.type === 'error');
|
||||
return errorItem?.data;
|
||||
}
|
||||
|
||||
// This class provides a minimal implementation focused solely on testing the toolCall method.
|
||||
// We cannot extend FrontendLanguageModelRegistryImpl directly due to issues in the test environment:
|
||||
// - FrontendLanguageModelRegistryImpl imports dependencies that transitively depend on 'p-queue'
|
||||
// - p-queue is an ESM-only module that cannot be loaded in the current test environment
|
||||
class TestableLanguageModelRegistry {
|
||||
private requests = new Map<string, LanguageModelRequest>();
|
||||
|
||||
async toolCall(id: string, toolId: string, arg_string: string): Promise<unknown> {
|
||||
if (!this.requests.has(id)) {
|
||||
return createToolCallError(`No request found for ID '${id}'. The request may have been cancelled or completed.`);
|
||||
}
|
||||
const request = this.requests.get(id)!;
|
||||
const tool = request.tools?.find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
try {
|
||||
return await tool.handler(arg_string);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return createToolCallError(`Error executing tool '${toolId}': ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
return createToolCallError(`Tool '${toolId}' not found in the available tools for this request.`, 'tool-not-available');
|
||||
}
|
||||
|
||||
// Test helper method
|
||||
setRequest(id: string, request: LanguageModelRequest): void {
|
||||
this.requests.set(id, request);
|
||||
}
|
||||
}
|
||||
|
||||
describe('FrontendLanguageModelRegistryImpl toolCall functionality', () => {
|
||||
let registry: TestableLanguageModelRegistry;
|
||||
|
||||
before(() => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new TestableLanguageModelRegistry();
|
||||
});
|
||||
|
||||
describe('toolCall', () => {
|
||||
it('should return error when request ID does not exist', async () => {
|
||||
const result = await registry.toolCall('nonexistent-id', 'test-tool', '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolCallError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const errorMessage = getFirstErrorMessage(result);
|
||||
expect(errorMessage).to.include('No request found for ID \'nonexistent-id\'');
|
||||
expect(errorMessage).to.include('The request may have been cancelled or completed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error when tool is not found', async () => {
|
||||
// Set up a request without the requested tool
|
||||
const requestId = 'test-request-id';
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
id: 'different-tool',
|
||||
name: 'Different Tool',
|
||||
description: 'A different tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => Promise.resolve('success')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, 'nonexistent-tool', '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolNotAvailableError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const errorMessage = getFirstErrorMessage(result);
|
||||
expect(errorMessage).to.include('Tool \'nonexistent-tool\' not found in the available tools for this request');
|
||||
}
|
||||
});
|
||||
|
||||
it('should call tool handler successfully when tool exists', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const toolId = 'test-tool';
|
||||
const expectedResult = 'tool execution result';
|
||||
|
||||
const mockTool: ToolRequest = {
|
||||
id: toolId,
|
||||
name: 'Test Tool',
|
||||
description: 'A test tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: (args: string) => Promise.resolve(expectedResult)
|
||||
};
|
||||
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: [mockTool]
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, toolId, '{}');
|
||||
|
||||
expect(result).to.equal(expectedResult);
|
||||
});
|
||||
|
||||
it('should handle synchronous tool handler errors gracefully', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const toolId = 'error-tool';
|
||||
const errorMessage = 'Tool execution failed';
|
||||
|
||||
const mockTool: ToolRequest = {
|
||||
id: toolId,
|
||||
name: 'Error Tool',
|
||||
description: 'A tool that throws an error',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: [mockTool]
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, toolId, '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolCallError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const resultErrorMessage = getFirstErrorMessage(result);
|
||||
expect(resultErrorMessage).to.include(`Error executing tool '${toolId}': ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions gracefully', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const toolId = 'string-error-tool';
|
||||
const errorMessage = 'String error';
|
||||
|
||||
const mockTool: ToolRequest = {
|
||||
id: toolId,
|
||||
name: 'String Error Tool',
|
||||
description: 'A tool that throws a string',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw errorMessage;
|
||||
}
|
||||
};
|
||||
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: [mockTool]
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, toolId, '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolCallError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const resultErrorMessage = getFirstErrorMessage(result);
|
||||
expect(resultErrorMessage).to.include(`Error executing tool '${toolId}': ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle asynchronous tool handler errors gracefully', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const toolId = 'async-error-tool';
|
||||
const errorMessage = 'Async tool execution failed';
|
||||
|
||||
const mockTool: ToolRequest = {
|
||||
id: toolId,
|
||||
name: 'Async Error Tool',
|
||||
description: 'A tool that returns a rejected promise',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => Promise.reject(new Error(errorMessage))
|
||||
};
|
||||
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: [mockTool]
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, toolId, '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolCallError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const resultErrorMessage = getFirstErrorMessage(result);
|
||||
expect(resultErrorMessage).to.include(`Error executing tool '${toolId}': ${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle tool handler with no tools array', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: []
|
||||
// No tools property
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, 'any-tool', '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolNotAvailableError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const resultErrorMessage = getFirstErrorMessage(result);
|
||||
expect(resultErrorMessage).to.include('Tool \'any-tool\' not found in the available tools for this request');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle tool handler with empty tools array', async () => {
|
||||
const requestId = 'test-request-id';
|
||||
const mockRequest: LanguageModelRequest = {
|
||||
messages: [],
|
||||
tools: []
|
||||
};
|
||||
|
||||
registry.setRequest(requestId, mockRequest);
|
||||
|
||||
const result = await registry.toolCall(requestId, 'any-tool', '{}');
|
||||
|
||||
expect(result).to.be.an('object');
|
||||
expect(isToolCallContent(result)).to.be.true;
|
||||
expect(hasToolNotAvailableError(result as ToolCallContent)).to.be.true;
|
||||
if (isToolCallContent(result)) {
|
||||
const resultErrorMessage = getFirstErrorMessage(result);
|
||||
expect(resultErrorMessage).to.include('Tool \'any-tool\' not found in the available tools for this request');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
474
packages/ai-core/src/browser/frontend-language-model-registry.ts
Normal file
474
packages/ai-core/src/browser/frontend-language-model-registry.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { CancellationToken } from '@theia/core';
|
||||
import {
|
||||
inject,
|
||||
injectable,
|
||||
postConstruct,
|
||||
} from '@theia/core/shared/inversify';
|
||||
import {
|
||||
OutputChannel,
|
||||
OutputChannelManager,
|
||||
OutputChannelSeverity,
|
||||
} from '@theia/output/lib/browser/output-channel';
|
||||
import {
|
||||
AISettingsService,
|
||||
createToolCallError,
|
||||
DefaultLanguageModelRegistryImpl,
|
||||
FrontendLanguageModelRegistry,
|
||||
isLanguageModelParsedResponse,
|
||||
isLanguageModelStreamResponse,
|
||||
isLanguageModelStreamResponseDelegate,
|
||||
isLanguageModelTextResponse,
|
||||
isTextResponsePart,
|
||||
LanguageModel,
|
||||
LanguageModelAliasRegistry,
|
||||
LanguageModelDelegateClient,
|
||||
LanguageModelFrontendDelegate,
|
||||
LanguageModelMetaData,
|
||||
LanguageModelRegistryClient,
|
||||
LanguageModelRegistryFrontendDelegate,
|
||||
LanguageModelRequest,
|
||||
LanguageModelResponse,
|
||||
LanguageModelSelector,
|
||||
LanguageModelStreamResponsePart,
|
||||
ToolCallResult,
|
||||
ToolInvocationContext
|
||||
} from '../common';
|
||||
|
||||
@injectable()
|
||||
export class LanguageModelDelegateClientImpl
|
||||
implements LanguageModelDelegateClient, LanguageModelRegistryClient {
|
||||
onLanguageModelUpdated(id: string): void {
|
||||
this.receiver.onLanguageModelUpdated(id);
|
||||
}
|
||||
protected receiver: FrontendLanguageModelRegistryImpl;
|
||||
|
||||
setReceiver(receiver: FrontendLanguageModelRegistryImpl): void {
|
||||
this.receiver = receiver;
|
||||
}
|
||||
|
||||
send(id: string, token: LanguageModelStreamResponsePart | undefined): void {
|
||||
this.receiver.send(id, token);
|
||||
}
|
||||
|
||||
toolCall(requestId: string, toolId: string, args_string: string, toolCallId?: string): Promise<ToolCallResult> {
|
||||
return this.receiver.toolCall(requestId, toolId, args_string, toolCallId);
|
||||
}
|
||||
|
||||
error(id: string, error: Error): void {
|
||||
this.receiver.error(id, error);
|
||||
}
|
||||
|
||||
languageModelAdded(metadata: LanguageModelMetaData): void {
|
||||
this.receiver.languageModelAdded(metadata);
|
||||
}
|
||||
|
||||
languageModelRemoved(id: string): void {
|
||||
this.receiver.languageModelRemoved(id);
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
id: string;
|
||||
tokens: (LanguageModelStreamResponsePart | undefined)[];
|
||||
resolve?: (_: unknown) => void;
|
||||
reject?: (_: unknown) => void;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class FrontendLanguageModelRegistryImpl
|
||||
extends DefaultLanguageModelRegistryImpl
|
||||
implements FrontendLanguageModelRegistry {
|
||||
|
||||
@inject(LanguageModelAliasRegistry)
|
||||
protected aliasRegistry: LanguageModelAliasRegistry;
|
||||
|
||||
// called by backend
|
||||
languageModelAdded(metadata: LanguageModelMetaData): void {
|
||||
this.addLanguageModels([metadata]);
|
||||
}
|
||||
// called by backend
|
||||
languageModelRemoved(id: string): void {
|
||||
this.removeLanguageModels([id]);
|
||||
}
|
||||
|
||||
// called by backend when a model is updated
|
||||
onLanguageModelUpdated(id: string): void {
|
||||
this.updateLanguageModelFromBackend(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the updated model metadata from the backend and update the registry.
|
||||
*/
|
||||
protected async updateLanguageModelFromBackend(id: string): Promise<void> {
|
||||
try {
|
||||
const backendModels = await this.registryDelegate.getLanguageModelDescriptions();
|
||||
const updated = backendModels.find((m: { id: string }) => m.id === id);
|
||||
if (updated) {
|
||||
// Remove the old model and add the updated one
|
||||
this.removeLanguageModels([id]);
|
||||
this.addLanguageModels([updated]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to update language model from backend', err);
|
||||
}
|
||||
}
|
||||
@inject(LanguageModelRegistryFrontendDelegate)
|
||||
protected registryDelegate: LanguageModelRegistryFrontendDelegate;
|
||||
|
||||
@inject(LanguageModelFrontendDelegate)
|
||||
protected providerDelegate: LanguageModelFrontendDelegate;
|
||||
|
||||
@inject(LanguageModelDelegateClientImpl)
|
||||
protected client: LanguageModelDelegateClientImpl;
|
||||
|
||||
@inject(OutputChannelManager)
|
||||
protected outputChannelManager: OutputChannelManager;
|
||||
|
||||
@inject(AISettingsService)
|
||||
protected settingsService: AISettingsService;
|
||||
|
||||
private static requestCounter: number = 0;
|
||||
|
||||
override addLanguageModels(models: LanguageModelMetaData[] | LanguageModel[]): void {
|
||||
let modelAdded = false;
|
||||
for (const model of models) {
|
||||
if (this.languageModels.find(m => m.id === model.id)) {
|
||||
console.warn(`Tried to add an existing model ${model.id}`);
|
||||
continue;
|
||||
}
|
||||
if (LanguageModel.is(model)) {
|
||||
this.languageModels.push(
|
||||
new Proxy(
|
||||
model,
|
||||
languageModelOutputHandler(
|
||||
() => this.outputChannelManager.getChannel(
|
||||
model.id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
modelAdded = true;
|
||||
} else {
|
||||
this.languageModels.push(
|
||||
new Proxy(
|
||||
this.createFrontendLanguageModel(
|
||||
model
|
||||
),
|
||||
languageModelOutputHandler(
|
||||
() => this.outputChannelManager.getChannel(
|
||||
model.id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
modelAdded = true;
|
||||
}
|
||||
}
|
||||
if (modelAdded) {
|
||||
this.changeEmitter.fire({ models: this.languageModels });
|
||||
}
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected override init(): void {
|
||||
this.client.setReceiver(this);
|
||||
|
||||
const contributions =
|
||||
this.languageModelContributions.getContributions();
|
||||
const promises = contributions.map(provider => provider());
|
||||
const backendDescriptions =
|
||||
this.registryDelegate.getLanguageModelDescriptions();
|
||||
|
||||
Promise.allSettled([backendDescriptions, ...promises]).then(
|
||||
results => {
|
||||
const backendDescriptionsResult = results[0];
|
||||
if (backendDescriptionsResult.status === 'fulfilled') {
|
||||
this.addLanguageModels(backendDescriptionsResult.value);
|
||||
} else {
|
||||
this.logger.error(
|
||||
'Failed to add language models contributed from the backend',
|
||||
backendDescriptionsResult.reason
|
||||
);
|
||||
}
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
// assert that index > 0 contains only language models
|
||||
const languageModelResult = results[i] as
|
||||
| PromiseRejectedResult
|
||||
| PromiseFulfilledResult<LanguageModel[]>;
|
||||
if (languageModelResult.status === 'fulfilled') {
|
||||
this.addLanguageModels(languageModelResult.value);
|
||||
} else {
|
||||
this.logger.error(
|
||||
'Failed to add some language models:',
|
||||
languageModelResult.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
this.markInitialized();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createFrontendLanguageModel(
|
||||
description: LanguageModelMetaData
|
||||
): LanguageModel {
|
||||
return {
|
||||
...description,
|
||||
request: async (request: LanguageModelRequest, cancellationToken?: CancellationToken) => {
|
||||
const requestId = `${FrontendLanguageModelRegistryImpl.requestCounter++}`;
|
||||
this.requests.set(requestId, request);
|
||||
cancellationToken?.onCancellationRequested(() => {
|
||||
this.providerDelegate.cancel(requestId);
|
||||
});
|
||||
const response = await this.providerDelegate.request(
|
||||
description.id,
|
||||
request,
|
||||
requestId,
|
||||
cancellationToken
|
||||
);
|
||||
if (isLanguageModelTextResponse(response) || isLanguageModelParsedResponse(response)) {
|
||||
return response;
|
||||
}
|
||||
if (isLanguageModelStreamResponseDelegate(response)) {
|
||||
if (!this.streams.has(response.streamId)) {
|
||||
const newStreamState = {
|
||||
id: response.streamId,
|
||||
tokens: [],
|
||||
};
|
||||
this.streams.set(response.streamId, newStreamState);
|
||||
}
|
||||
const streamState = this.streams.get(response.streamId)!;
|
||||
return {
|
||||
stream: this.getIterable(streamState),
|
||||
};
|
||||
}
|
||||
this.logger.error(
|
||||
`Received unknown response in frontend for request to language model ${description.id}. Trying to continue without touching the response.`,
|
||||
response
|
||||
);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected streams = new Map<string, StreamState>();
|
||||
protected requests = new Map<string, LanguageModelRequest>();
|
||||
|
||||
async *getIterable(
|
||||
state: StreamState
|
||||
): AsyncIterable<LanguageModelStreamResponsePart> {
|
||||
let current = -1;
|
||||
while (true) {
|
||||
if (current < state.tokens.length - 1) {
|
||||
current++;
|
||||
const token = state.tokens[current];
|
||||
if (token === undefined) {
|
||||
// message is finished
|
||||
break;
|
||||
}
|
||||
if (token !== undefined) {
|
||||
yield token;
|
||||
}
|
||||
} else {
|
||||
await new Promise((resolve, reject) => {
|
||||
state.resolve = resolve;
|
||||
state.reject = reject;
|
||||
});
|
||||
}
|
||||
}
|
||||
this.streams.delete(state.id);
|
||||
}
|
||||
|
||||
// called by backend via the "delegate client" with new tokens
|
||||
send(id: string, token: LanguageModelStreamResponsePart | undefined): void {
|
||||
if (!this.streams.has(id)) {
|
||||
const newStreamState = {
|
||||
id,
|
||||
tokens: [],
|
||||
};
|
||||
this.streams.set(id, newStreamState);
|
||||
}
|
||||
const streamState = this.streams.get(id)!;
|
||||
streamState.tokens.push(token);
|
||||
if (streamState.resolve) {
|
||||
streamState.resolve(token);
|
||||
}
|
||||
}
|
||||
|
||||
// called by backend once tool is invoked
|
||||
async toolCall(id: string, toolId: string, arg_string: string, toolCallId?: string): Promise<ToolCallResult> {
|
||||
if (!this.requests.has(id)) {
|
||||
return createToolCallError(`No request found for ID '${id}'. The request may have been cancelled or completed.`);
|
||||
}
|
||||
const request = this.requests.get(id)!;
|
||||
const tool = request.tools?.find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
try {
|
||||
return await tool.handler(arg_string, ToolInvocationContext.create(toolCallId));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return createToolCallError(`Error executing tool '${toolId}': ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
return createToolCallError(`Tool '${toolId}' not found in the available tools for this request.`, 'tool-not-available');
|
||||
}
|
||||
|
||||
// called by backend via the "delegate client" with the error to use for rejection
|
||||
error(id: string, error: Error): void {
|
||||
if (!this.streams.has(id)) {
|
||||
const newStreamState = {
|
||||
id,
|
||||
tokens: [],
|
||||
};
|
||||
this.streams.set(id, newStreamState);
|
||||
}
|
||||
const streamState = this.streams.get(id)!;
|
||||
streamState.reject?.(error);
|
||||
}
|
||||
|
||||
override async selectLanguageModels(request: LanguageModelSelector): Promise<LanguageModel[] | undefined> {
|
||||
await this.initialized;
|
||||
const userSettings = (await this.settingsService.getAgentSettings(request.agent))?.languageModelRequirements?.find(req => req.purpose === request.purpose);
|
||||
const identifier = userSettings?.identifier ?? request.identifier;
|
||||
if (identifier) {
|
||||
const model = await this.getReadyLanguageModel(identifier);
|
||||
if (model) {
|
||||
return [model];
|
||||
}
|
||||
}
|
||||
// Previously we returned the default model here, but this is not really transparent for the user so we do not select any model here.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getReadyLanguageModel(idOrAlias: string): Promise<LanguageModel | undefined> {
|
||||
await this.aliasRegistry.ready;
|
||||
const modelIds = this.aliasRegistry.resolveAlias(idOrAlias);
|
||||
if (modelIds) {
|
||||
for (const modelId of modelIds) {
|
||||
const model = await this.getLanguageModel(modelId);
|
||||
if (model?.status.status === 'ready') {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const languageModel = await this.getLanguageModel(idOrAlias);
|
||||
return languageModel?.status.status === 'ready' ? languageModel : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const formatJsonWithIndentation = (obj: unknown): string[] => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const jsonString = JSON.stringify(obj, null, 2);
|
||||
const lines = jsonString.split('\n');
|
||||
const formattedLines: string[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const subLines = line.split('\\n');
|
||||
const index = indexOfValue(subLines[0]) + 1;
|
||||
formattedLines.push(subLines[0]);
|
||||
const prefix = index > 0 ? ' '.repeat(index) : '';
|
||||
if (index !== -1) {
|
||||
for (let i = 1; i < subLines.length; i++) {
|
||||
formattedLines.push(prefix + subLines[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return formattedLines;
|
||||
};
|
||||
|
||||
const indexOfValue = (jsonLine: string): number => {
|
||||
const pattern = /"([^"]+)"\s*:\s*/g;
|
||||
const match = pattern.exec(jsonLine);
|
||||
return match ? match.index + match[0].length : -1;
|
||||
};
|
||||
|
||||
const languageModelOutputHandler = (
|
||||
outputChannelGetter: () => OutputChannel
|
||||
): ProxyHandler<LanguageModel> => ({
|
||||
get<K extends keyof LanguageModel>(
|
||||
target: LanguageModel,
|
||||
prop: K,
|
||||
): LanguageModel[K] | LanguageModel['request'] {
|
||||
const original = target[prop];
|
||||
if (prop === 'request' && typeof original === 'function') {
|
||||
return async function (
|
||||
...args: Parameters<LanguageModel['request']>
|
||||
): Promise<LanguageModelResponse> {
|
||||
const outputChannel = outputChannelGetter();
|
||||
outputChannel.appendLine(
|
||||
'Sending request:'
|
||||
);
|
||||
const formattedRequest = formatJsonWithIndentation(args[0]);
|
||||
outputChannel.append(formattedRequest.join('\n'));
|
||||
if (args[1]) {
|
||||
args[1] = new Proxy(args[1], {
|
||||
get<CK extends keyof CancellationToken>(
|
||||
cTarget: CancellationToken,
|
||||
cProp: CK
|
||||
): CancellationToken[CK] | CancellationToken['onCancellationRequested'] {
|
||||
if (cProp === 'onCancellationRequested') {
|
||||
return (...cargs: Parameters<CancellationToken['onCancellationRequested']>) => cTarget.onCancellationRequested(() => {
|
||||
outputChannel.appendLine('\nCancel requested', OutputChannelSeverity.Warning);
|
||||
cargs[0]();
|
||||
}, cargs[1], cargs[2]);
|
||||
}
|
||||
return cTarget[cProp];
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await original.apply(target, args);
|
||||
if (isLanguageModelStreamResponse(result)) {
|
||||
outputChannel.appendLine('Received a response stream');
|
||||
const stream = result.stream;
|
||||
const loggedStream = {
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<LanguageModelStreamResponsePart> {
|
||||
for await (const part of stream) {
|
||||
outputChannel.append((isTextResponsePart(part) && part.content) || '');
|
||||
yield part;
|
||||
}
|
||||
outputChannel.append('\n');
|
||||
outputChannel.appendLine('End of stream');
|
||||
},
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
stream: loggedStream,
|
||||
};
|
||||
} else {
|
||||
outputChannel.appendLine('Received a response');
|
||||
outputChannel.appendLine(JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
outputChannel.appendLine('An error occurred');
|
||||
if (err instanceof Error) {
|
||||
outputChannel.appendLine(
|
||||
err.message,
|
||||
OutputChannelSeverity.Error
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
return original;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { Prioritizeable } from '@theia/core/lib/common/prioritizeable';
|
||||
import { LanguageModel, LanguageModelResponse, UserRequest } from '../common';
|
||||
import { LanguageModelServiceImpl } from '../common/language-model-service';
|
||||
import { PREFERENCE_NAME_REQUEST_SETTINGS, RequestSetting, getRequestSettingSpecificity } from '../common/ai-core-preferences';
|
||||
|
||||
@injectable()
|
||||
export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected preferenceService: PreferenceService;
|
||||
|
||||
override async sendRequest(
|
||||
languageModel: LanguageModel,
|
||||
languageModelRequest: UserRequest
|
||||
): Promise<LanguageModelResponse> {
|
||||
const requestSettings = this.preferenceService.get<RequestSetting[]>(PREFERENCE_NAME_REQUEST_SETTINGS, []);
|
||||
|
||||
const ids = languageModel.id.split('/');
|
||||
const matchingSetting = mergeRequestSettings(requestSettings, ids[1], ids[0], languageModelRequest.agentId);
|
||||
if (matchingSetting?.requestSettings) {
|
||||
// Merge the settings, with user request taking precedence
|
||||
languageModelRequest.settings = {
|
||||
...matchingSetting.requestSettings,
|
||||
...languageModelRequest.settings
|
||||
};
|
||||
}
|
||||
if (matchingSetting?.clientSettings) {
|
||||
// Merge the clientSettings, with user request taking precedence
|
||||
languageModelRequest.clientSettings = {
|
||||
...matchingSetting.clientSettings,
|
||||
...languageModelRequest.clientSettings
|
||||
};
|
||||
}
|
||||
|
||||
return super.sendRequest(languageModel, languageModelRequest);
|
||||
}
|
||||
}
|
||||
|
||||
export const mergeRequestSettings = (requestSettings: RequestSetting[], modelId: string, providerId: string, agentId?: string): RequestSetting => {
|
||||
const prioritizedSettings = Prioritizeable.prioritizeAllSync(requestSettings,
|
||||
setting => getRequestSettingSpecificity(setting, {
|
||||
modelId,
|
||||
providerId,
|
||||
agentId
|
||||
}));
|
||||
// merge all settings from lowest to highest, identical priorities will be overwritten by the following
|
||||
const matchingSetting = prioritizedSettings.reduceRight((acc, cur) => ({ ...acc, ...cur.value }), {} as RequestSetting);
|
||||
return matchingSetting;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
|
||||
|
||||
describe('Prompt Template Parser', () => {
|
||||
|
||||
describe('YAML Front Matter Parsing', () => {
|
||||
it('extracts YAML front matter correctly', () => {
|
||||
const fileContent = `---
|
||||
isCommand: true
|
||||
commandName: hello
|
||||
commandDescription: Say hello
|
||||
commandArgumentHint: <name>
|
||||
commandAgents:
|
||||
- Universal
|
||||
- Agent2
|
||||
---
|
||||
Template content here`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.template).to.equal('Template content here');
|
||||
expect(result.metadata).to.not.be.undefined;
|
||||
expect(result.metadata?.isCommand).to.be.true;
|
||||
expect(result.metadata?.commandName).to.equal('hello');
|
||||
expect(result.metadata?.commandDescription).to.equal('Say hello');
|
||||
expect(result.metadata?.commandArgumentHint).to.equal('<name>');
|
||||
expect(result.metadata?.commandAgents).to.deep.equal(['Universal', 'Agent2']);
|
||||
});
|
||||
|
||||
it('returns template without front matter when none exists', () => {
|
||||
const fileContent = 'Just a regular template';
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.template).to.equal('Just a regular template');
|
||||
expect(result.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('handles missing front matter gracefully', () => {
|
||||
const fileContent = `---
|
||||
This is not valid YAML front matter
|
||||
Template content`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
// Should return content as-is when front matter is invalid
|
||||
expect(result.template).to.equal(fileContent);
|
||||
});
|
||||
|
||||
it('handles invalid YAML gracefully', () => {
|
||||
const fileContent = `---
|
||||
isCommand: true
|
||||
commandName: [unclosed array
|
||||
---
|
||||
Template content`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
// Should return template without metadata on parse error
|
||||
expect(result.template).to.equal(fileContent);
|
||||
expect(result.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('validates command metadata types', () => {
|
||||
const fileContent = `---
|
||||
isCommand: "true"
|
||||
commandName: 123
|
||||
commandDescription: valid
|
||||
commandArgumentHint: <arg>
|
||||
commandAgents: "not-an-array"
|
||||
---
|
||||
Template`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.template).to.equal('Template');
|
||||
expect(result.metadata?.isCommand).to.be.undefined; // Wrong type
|
||||
expect(result.metadata?.commandName).to.be.undefined; // Wrong type
|
||||
expect(result.metadata?.commandDescription).to.equal('valid');
|
||||
expect(result.metadata?.commandArgumentHint).to.equal('<arg>');
|
||||
expect(result.metadata?.commandAgents).to.be.undefined; // Wrong type
|
||||
});
|
||||
|
||||
it('filters commandAgents to strings only', () => {
|
||||
const fileContent = `---
|
||||
commandAgents:
|
||||
- ValidAgent
|
||||
- 123
|
||||
- AnotherValid
|
||||
- true
|
||||
- LastValid
|
||||
---
|
||||
Template`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.metadata?.commandAgents).to.deep.equal(['ValidAgent', 'AnotherValid', 'LastValid']);
|
||||
});
|
||||
|
||||
it('handles partial metadata fields', () => {
|
||||
const fileContent = `---
|
||||
isCommand: true
|
||||
commandName: test
|
||||
---
|
||||
Template content`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.template).to.equal('Template content');
|
||||
expect(result.metadata?.isCommand).to.be.true;
|
||||
expect(result.metadata?.commandName).to.equal('test');
|
||||
expect(result.metadata?.commandDescription).to.be.undefined;
|
||||
expect(result.metadata?.commandArgumentHint).to.be.undefined;
|
||||
expect(result.metadata?.commandAgents).to.be.undefined;
|
||||
});
|
||||
|
||||
it('preserves template content with special characters', () => {
|
||||
const fileContent = `---
|
||||
isCommand: true
|
||||
---
|
||||
Template with $ARGUMENTS and {{variable}} and ~{function}`;
|
||||
|
||||
const result: ParsedTemplate = parseTemplateWithMetadata(fileContent);
|
||||
|
||||
expect(result.template).to.equal('Template with $ARGUMENTS and {{variable}} and ~{function}');
|
||||
expect(result.metadata?.isCommand).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
217
packages/ai-core/src/browser/frontend-variable-service.ts
Normal file
217
packages/ai-core/src/browser/frontend-variable-service.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Disposable, MessageService, nls, Prioritizeable } from '@theia/core';
|
||||
import { FrontendApplicationContribution, OpenerService, open } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
AIVariable,
|
||||
AIVariableArg,
|
||||
AIVariableContext,
|
||||
AIVariableOpener,
|
||||
AIVariableResolutionRequest,
|
||||
AIVariableResourceResolver,
|
||||
AIVariableService,
|
||||
DefaultAIVariableService,
|
||||
PromptText
|
||||
} from '../common';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
|
||||
export type AIVariableDropHandler = (event: DragEvent, context: AIVariableContext) => Promise<AIVariableDropResult | undefined>;
|
||||
|
||||
export interface AIVariableDropResult {
|
||||
variables: AIVariableResolutionRequest[],
|
||||
text?: string
|
||||
};
|
||||
|
||||
export type AIVariablePasteHandler = (event: ClipboardEvent, context: AIVariableContext) => Promise<AIVariablePasteResult | undefined>;
|
||||
|
||||
export interface AIVariablePasteResult {
|
||||
variables: AIVariableResolutionRequest[],
|
||||
text?: string
|
||||
};
|
||||
|
||||
export interface AIVariableCompletionContext {
|
||||
/** Portion of user input to be used for filtering completion candidates. */
|
||||
userInput: string;
|
||||
/** The range of suggestion completions. */
|
||||
range: monaco.Range
|
||||
/** A prefix to be applied to each completion item's text */
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export namespace AIVariableCompletionContext {
|
||||
export function get(
|
||||
variableName: string,
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
matchString?: string
|
||||
): AIVariableCompletionContext | undefined {
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
const indexOfVariableTrigger = lineContent.lastIndexOf(matchString ?? PromptText.VARIABLE_CHAR, position.column - 1);
|
||||
|
||||
// check if there is a variable trigger and no space typed between the variable trigger and the cursor
|
||||
if (indexOfVariableTrigger === -1 || lineContent.substring(indexOfVariableTrigger).includes(' ')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// determine whether we are providing completions before or after the variable argument separator
|
||||
const indexOfVariableArgSeparator = lineContent.lastIndexOf(PromptText.VARIABLE_SEPARATOR_CHAR, position.column - 1);
|
||||
const triggerCharIndex = Math.max(indexOfVariableTrigger, indexOfVariableArgSeparator);
|
||||
|
||||
const userInput = lineContent.substring(triggerCharIndex + 1, position.column - 1);
|
||||
const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column);
|
||||
const matchVariableChar = lineContent[triggerCharIndex] === (matchString ? matchString : PromptText.VARIABLE_CHAR);
|
||||
const prefix = matchVariableChar ? variableName + PromptText.VARIABLE_SEPARATOR_CHAR : '';
|
||||
return { range, userInput, prefix };
|
||||
}
|
||||
}
|
||||
|
||||
export const FrontendVariableService = Symbol('FrontendVariableService');
|
||||
export interface FrontendVariableService extends AIVariableService {
|
||||
registerDropHandler(handler: AIVariableDropHandler): Disposable;
|
||||
unregisterDropHandler(handler: AIVariableDropHandler): void;
|
||||
getDropResult(event: DragEvent, context: AIVariableContext): Promise<AIVariableDropResult>;
|
||||
|
||||
registerPasteHandler(handler: AIVariablePasteHandler): Disposable;
|
||||
unregisterPasteHandler(handler: AIVariablePasteHandler): void;
|
||||
getPasteResult(event: ClipboardEvent, context: AIVariableContext): Promise<AIVariablePasteResult>;
|
||||
|
||||
registerOpener(variable: AIVariable, opener: AIVariableOpener): Disposable;
|
||||
unregisterOpener(variable: AIVariable, opener: AIVariableOpener): void;
|
||||
getOpener(name: string, arg: string | undefined, context: AIVariableContext): Promise<AIVariableOpener | undefined>;
|
||||
open(variable: AIVariableArg, context?: AIVariableContext): Promise<void>
|
||||
}
|
||||
|
||||
export interface FrontendVariableContribution {
|
||||
registerVariables(service: FrontendVariableService): void;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class DefaultFrontendVariableService extends DefaultAIVariableService implements FrontendApplicationContribution, FrontendVariableService {
|
||||
protected dropHandlers = new Set<AIVariableDropHandler>();
|
||||
protected pasteHandlers = new Set<AIVariablePasteHandler>();
|
||||
|
||||
@inject(MessageService) protected readonly messageService: MessageService;
|
||||
@inject(AIVariableResourceResolver) protected readonly aiResourceResolver: AIVariableResourceResolver;
|
||||
@inject(OpenerService) protected readonly openerService: OpenerService;
|
||||
|
||||
onStart(): void {
|
||||
this.initContributions();
|
||||
}
|
||||
|
||||
registerDropHandler(handler: AIVariableDropHandler): Disposable {
|
||||
this.dropHandlers.add(handler);
|
||||
return Disposable.create(() => this.unregisterDropHandler(handler));
|
||||
}
|
||||
|
||||
unregisterDropHandler(handler: AIVariableDropHandler): void {
|
||||
this.dropHandlers.delete(handler);
|
||||
}
|
||||
|
||||
async getDropResult(event: DragEvent, context: AIVariableContext): Promise<AIVariableDropResult> {
|
||||
let text: string | undefined = undefined;
|
||||
const variables: AIVariableResolutionRequest[] = [];
|
||||
for (const handler of this.dropHandlers) {
|
||||
const result = await handler(event, context);
|
||||
if (result) {
|
||||
variables.push(...result.variables);
|
||||
if (text === undefined) {
|
||||
text = result.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { variables, text };
|
||||
}
|
||||
|
||||
registerPasteHandler(handler: AIVariablePasteHandler): Disposable {
|
||||
this.pasteHandlers.add(handler);
|
||||
return Disposable.create(() => this.unregisterPasteHandler(handler));
|
||||
}
|
||||
|
||||
unregisterPasteHandler(handler: AIVariablePasteHandler): void {
|
||||
this.pasteHandlers.delete(handler);
|
||||
}
|
||||
|
||||
async getPasteResult(event: ClipboardEvent, context: AIVariableContext): Promise<AIVariablePasteResult> {
|
||||
let text: string | undefined = undefined;
|
||||
const variables: AIVariableResolutionRequest[] = [];
|
||||
for (const handler of this.pasteHandlers) {
|
||||
const result = await handler(event, context);
|
||||
if (result) {
|
||||
variables.push(...result.variables);
|
||||
if (text === undefined) {
|
||||
text = result.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { variables, text };
|
||||
}
|
||||
|
||||
registerOpener(variable: AIVariable, opener: AIVariableOpener): Disposable {
|
||||
const key = this.getKey(variable.name);
|
||||
if (!this.variables.get(key)) {
|
||||
this.variables.set(key, variable);
|
||||
this.onDidChangeVariablesEmitter.fire();
|
||||
}
|
||||
const openers = this.openers.get(key) ?? [];
|
||||
openers.push(opener);
|
||||
this.openers.set(key, openers);
|
||||
return Disposable.create(() => this.unregisterOpener(variable, opener));
|
||||
}
|
||||
|
||||
unregisterOpener(variable: AIVariable, opener: AIVariableOpener): void {
|
||||
const key = this.getKey(variable.name);
|
||||
const registeredOpeners = this.openers.get(key);
|
||||
registeredOpeners?.splice(registeredOpeners.indexOf(opener), 1);
|
||||
}
|
||||
|
||||
async getOpener(name: string, arg: string | undefined, context: AIVariableContext = {}): Promise<AIVariableOpener | undefined> {
|
||||
const variable = this.getVariable(name);
|
||||
return variable && Prioritizeable.prioritizeAll(
|
||||
this.openers.get(this.getKey(name)) ?? [],
|
||||
opener => (async () => opener.canOpen({ variable, arg }, context))().catch(() => 0)
|
||||
)
|
||||
.then(prioritized => prioritized.at(0)?.value);
|
||||
}
|
||||
|
||||
async open(request: AIVariableArg, context?: AIVariableContext | undefined): Promise<void> {
|
||||
const { variableName, arg } = this.parseRequest(request);
|
||||
const variable = this.getVariable(variableName);
|
||||
if (!variable) {
|
||||
this.messageService.warn(nls.localize('theia/ai/core/noVariableFoundForOpenRequest', 'No variable found for open request.'));
|
||||
return;
|
||||
}
|
||||
const opener = await this.getOpener(variableName, arg, context);
|
||||
try {
|
||||
return opener ? opener.open({ variable, arg }, context ?? {}) : this.openReadonly({ variable, arg }, context);
|
||||
} catch (err) {
|
||||
console.error('Unable to open variable:', err);
|
||||
this.messageService.error(nls.localize('theia/ai/core/unableToDisplayVariableValue', 'Unable to display variable value.'));
|
||||
}
|
||||
}
|
||||
|
||||
protected async openReadonly(request: AIVariableResolutionRequest, context: AIVariableContext = {}): Promise<void> {
|
||||
const resolved = await this.resolveVariable(request, context);
|
||||
if (resolved === undefined) {
|
||||
this.messageService.warn(nls.localize('theia/ai/core/unableToResolveVariable', 'Unable to resolve variable.'));
|
||||
return;
|
||||
}
|
||||
const resource = this.aiResourceResolver.getOrCreate(request, context, resolved.value);
|
||||
await open(this.openerService, resource.uri);
|
||||
resource.dispose();
|
||||
}
|
||||
}
|
||||
38
packages/ai-core/src/browser/index.ts
Normal file
38
packages/ai-core/src/browser/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export * from './agent-completion-notification-service';
|
||||
export * from './os-notification-service';
|
||||
export * from './window-blink-service';
|
||||
export * from './ai-activation-service';
|
||||
export * from './ai-command-handler-factory';
|
||||
export * from './ai-core-frontend-application-contribution';
|
||||
export * from './ai-core-frontend-module';
|
||||
export * from '../common/ai-core-preferences';
|
||||
export * from './ai-settings-service';
|
||||
export * from './ai-view-contribution';
|
||||
export * from './frontend-language-model-registry';
|
||||
export * from './frontend-language-model-alias-registry';
|
||||
export * from './frontend-variable-service';
|
||||
export * from './prompttemplate-contribution';
|
||||
export * from './theia-variable-contribution';
|
||||
export * from './open-editors-variable-contribution';
|
||||
export * from './skills-variable-contribution';
|
||||
export * from './skill-service';
|
||||
export * from './skill-prompt-coordinator';
|
||||
export * from './frontend-variable-service';
|
||||
export * from './ai-core-command-contribution';
|
||||
export * from '../common/language-model-service';
|
||||
@@ -0,0 +1,88 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import { EditorManager } from '@theia/editor/lib/browser';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from '../common';
|
||||
|
||||
export const OPEN_EDITORS_VARIABLE: AIVariable = {
|
||||
id: 'openEditors',
|
||||
description: nls.localize('theia/ai/core/openEditorsVariable/description', 'A comma-separated list of all currently open files, relative to the workspace root.'),
|
||||
name: 'openEditors',
|
||||
};
|
||||
|
||||
export const OPEN_EDITORS_SHORT_VARIABLE: AIVariable = {
|
||||
id: 'openEditorsShort',
|
||||
description: nls.localize('theia/ai/core/openEditorsShortVariable/description', 'Short reference to all currently open files (relative paths, comma-separated)'),
|
||||
name: '_ff',
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class OpenEditorsVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
|
||||
@inject(EditorManager)
|
||||
protected readonly editorManager: EditorManager;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(OPEN_EDITORS_VARIABLE, this);
|
||||
service.registerResolver(OPEN_EDITORS_SHORT_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise<number> {
|
||||
return (request.variable.name === OPEN_EDITORS_VARIABLE.name || request.variable.name === OPEN_EDITORS_SHORT_VARIABLE.name) ? 50 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
if (request.variable.name !== OPEN_EDITORS_VARIABLE.name && request.variable.name !== OPEN_EDITORS_SHORT_VARIABLE.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const openFiles = this.getAllOpenFilesRelative();
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: openFiles
|
||||
};
|
||||
}
|
||||
|
||||
protected getAllOpenFilesRelative(): string {
|
||||
const openFiles: string[] = [];
|
||||
|
||||
// Get all open editors from the editor manager
|
||||
for (const editor of this.editorManager.all) {
|
||||
const uri = editor.getResourceUri();
|
||||
if (uri) {
|
||||
const relativePath = this.getWorkspaceRelativePath(uri);
|
||||
if (relativePath) {
|
||||
openFiles.push(`'${relativePath}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openFiles.join(', ');
|
||||
}
|
||||
|
||||
protected getWorkspaceRelativePath(uri: URI): string | undefined {
|
||||
const workspaceRootUri = this.workspaceService.getWorkspaceRootUri(uri);
|
||||
const path = workspaceRootUri && workspaceRootUri.path.relative(uri.path);
|
||||
return path && path.toString();
|
||||
}
|
||||
}
|
||||
271
packages/ai-core/src/browser/os-notification-service.ts
Normal file
271
packages/ai-core/src/browser/os-notification-service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { environment } from '@theia/core';
|
||||
|
||||
/**
|
||||
* Configuration options for OS notifications
|
||||
*/
|
||||
export interface OSNotificationOptions {
|
||||
/** The notification body text */
|
||||
body?: string;
|
||||
/** Icon to display with the notification */
|
||||
icon?: string;
|
||||
/** Whether the notification should be silent */
|
||||
silent?: boolean;
|
||||
/** Tag to group notifications */
|
||||
tag?: string;
|
||||
/** Whether the notification requires user interaction to dismiss */
|
||||
requireInteraction?: boolean;
|
||||
/** Custom data to associate with the notification */
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an OS notification attempt
|
||||
*/
|
||||
export interface OSNotificationResult {
|
||||
/** Whether the notification was successfully shown */
|
||||
success: boolean;
|
||||
/** Error message if the notification failed */
|
||||
error?: string;
|
||||
/** The created notification instance (if successful) */
|
||||
notification?: Notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to handle OS-level notifications across different platforms
|
||||
* Provides fallback mechanisms for environments where notifications are unavailable
|
||||
*/
|
||||
@injectable()
|
||||
export class OSNotificationService {
|
||||
|
||||
private isElectron: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isElectron = environment.electron.is();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an OS-level notification with the given title and options
|
||||
*
|
||||
* @param title The notification title
|
||||
* @param options Optional notification configuration
|
||||
* @returns Promise resolving to the notification result
|
||||
*/
|
||||
async showNotification(title: string, options: OSNotificationOptions = {}): Promise<OSNotificationResult> {
|
||||
try {
|
||||
if (!this.isNotificationSupported()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Notifications are not supported in this environment'
|
||||
};
|
||||
}
|
||||
|
||||
const permission = await this.ensurePermission();
|
||||
if (permission !== 'granted') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Notification permission ${permission}`
|
||||
};
|
||||
}
|
||||
|
||||
const notification = await this.createNotification(title, options);
|
||||
return {
|
||||
success: true,
|
||||
notification
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to show OS notification:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification permission is granted
|
||||
*
|
||||
* @returns The current notification permission state
|
||||
*/
|
||||
getPermissionStatus(): NotificationPermission {
|
||||
if (!this.isNotificationSupported()) {
|
||||
return 'denied';
|
||||
}
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission from the user
|
||||
*
|
||||
* @returns Promise resolving to the permission state
|
||||
*/
|
||||
async requestPermission(): Promise<NotificationPermission> {
|
||||
if (!this.isNotificationSupported()) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'default') {
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission;
|
||||
} catch (error) {
|
||||
console.error('Failed to request notification permission:', error);
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OS notifications are supported in the current environment
|
||||
*
|
||||
* @returns true if notifications are supported, false otherwise
|
||||
*/
|
||||
isNotificationSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'Notification' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification specifically for agent completion
|
||||
* This is a convenience method with pre-configured options for agent notifications
|
||||
*
|
||||
* @param agentName The name of the agent that completed
|
||||
* @param taskDescription Optional description of the completed task
|
||||
* @returns Promise resolving to the notification result
|
||||
*/
|
||||
async showAgentCompletionNotification(agentName: string, taskDescription?: string): Promise<OSNotificationResult> {
|
||||
const title = nls.localize('theia/ai-core/agentCompletionTitle', 'Agent "{0}" Task Completed', agentName);
|
||||
const body = taskDescription
|
||||
? nls.localize('theia/ai-core/agentCompletionWithTask',
|
||||
'Agent "{0}" has completed the task: {1}', agentName, taskDescription)
|
||||
: nls.localize('theia/ai-core/agentCompletionMessage',
|
||||
'Agent "{0}" has completed its task.', agentName);
|
||||
|
||||
return this.showNotification(title, {
|
||||
body,
|
||||
icon: this.getAgentCompletionIcon(),
|
||||
tag: `agent-completion-${agentName}`,
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: 'agent-completion',
|
||||
agentName,
|
||||
taskDescription,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure notification permission is granted
|
||||
*
|
||||
* @returns Promise resolving to the permission state
|
||||
*/
|
||||
private async ensurePermission(): Promise<NotificationPermission> {
|
||||
const currentPermission = this.getPermissionStatus();
|
||||
|
||||
if (currentPermission === 'granted') {
|
||||
return currentPermission;
|
||||
}
|
||||
|
||||
if (currentPermission === 'denied') {
|
||||
return currentPermission;
|
||||
}
|
||||
|
||||
return this.requestPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a native notification with the given title and options
|
||||
*
|
||||
* @param title The notification title
|
||||
* @param options The notification options
|
||||
* @returns Promise resolving to the created notification
|
||||
*/
|
||||
private async createNotification(title: string, options: OSNotificationOptions): Promise<Notification> {
|
||||
return new Promise<Notification>((resolve, reject): void => {
|
||||
try {
|
||||
const notificationOptions: NotificationOptions = {
|
||||
body: options.body,
|
||||
icon: options.icon,
|
||||
silent: options.silent,
|
||||
tag: options.tag,
|
||||
requireInteraction: options.requireInteraction,
|
||||
data: options.data
|
||||
};
|
||||
|
||||
const notification = new Notification(title, notificationOptions);
|
||||
|
||||
notification.onshow = () => {
|
||||
console.debug('OS notification shown:', title);
|
||||
};
|
||||
|
||||
notification.onerror = error => {
|
||||
console.error('OS notification error:', error);
|
||||
reject(new Error('Failed to show notification'));
|
||||
};
|
||||
|
||||
notification.onclick = () => {
|
||||
console.debug('OS notification clicked:', title);
|
||||
this.focusApplicationWindow();
|
||||
notification.close();
|
||||
};
|
||||
|
||||
notification.onclose = () => {
|
||||
console.debug('OS notification closed:', title);
|
||||
};
|
||||
|
||||
resolve(notification);
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to focus the application window when notification is clicked
|
||||
*/
|
||||
private focusApplicationWindow(): void {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.focus();
|
||||
|
||||
if (this.isElectron && (window as unknown as { electronTheiaCore?: { focusWindow?: () => void } }).electronTheiaCore?.focusWindow) {
|
||||
(window as unknown as { electronTheiaCore: { focusWindow: () => void } }).electronTheiaCore.focusWindow();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not focus application window:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon URL for agent completion notifications
|
||||
*
|
||||
* @returns The icon URL or undefined if not available
|
||||
*/
|
||||
private getAgentCompletionIcon(): string | undefined {
|
||||
// This could return a path to an icon file
|
||||
// For now, we'll return undefined to use the default system icon
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
307
packages/ai-core/src/browser/prompttemplate-contribution.ts
Normal file
307
packages/ai-core/src/browser/prompttemplate-contribution.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { GrammarDefinition, GrammarDefinitionProvider, LanguageGrammarDefinitionContribution, TextmateRegistry } from '@theia/monaco/lib/browser/textmate';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import { Command, CommandContribution, CommandRegistry, nls } from '@theia/core';
|
||||
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
|
||||
|
||||
import { codicon, Widget } from '@theia/core/lib/browser';
|
||||
import { EditorWidget, ReplaceOperation } from '@theia/editor/lib/browser';
|
||||
import { PromptService, PromptText, ToolInvocationRegistry } from '../common';
|
||||
import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
|
||||
import { AIVariableService } from '../common/variable-service';
|
||||
|
||||
const PROMPT_TEMPLATE_LANGUAGE_ID = 'theia-ai-prompt-template';
|
||||
const PROMPT_TEMPLATE_TEXTMATE_SCOPE = 'source.prompttemplate';
|
||||
|
||||
export const PROMPT_TEMPLATE_EXTENSION = '.prompttemplate';
|
||||
|
||||
export const DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS: Command = Command.toLocalizedCommand({
|
||||
id: 'theia-ai-prompt-template:discard',
|
||||
label: 'Discard AI Prompt Template',
|
||||
iconClass: codicon('discard'),
|
||||
category: 'AI Prompt Templates'
|
||||
}, 'theia/ai/core/discard/label', 'theia/ai/core/prompts/category');
|
||||
|
||||
@injectable()
|
||||
export class PromptTemplateContribution implements LanguageGrammarDefinitionContribution, CommandContribution, TabBarToolbarContribution {
|
||||
|
||||
@inject(PromptService)
|
||||
private readonly promptService: PromptService;
|
||||
|
||||
@inject(ToolInvocationRegistry)
|
||||
protected readonly toolInvocationRegistry: ToolInvocationRegistry;
|
||||
|
||||
@inject(AIVariableService)
|
||||
protected readonly variableService: AIVariableService;
|
||||
|
||||
readonly config: monaco.languages.LanguageConfiguration =
|
||||
{
|
||||
'brackets': [
|
||||
['${', '}'],
|
||||
['~{', '}'],
|
||||
['{{', '}}'],
|
||||
['{{{', '}}}']
|
||||
],
|
||||
'autoClosingPairs': [
|
||||
{ 'open': '${', 'close': '}' },
|
||||
{ 'open': '~{', 'close': '}' },
|
||||
{ 'open': '{{', 'close': '}}' },
|
||||
{ 'open': '{{{', 'close': '}}}' }
|
||||
],
|
||||
'surroundingPairs': [
|
||||
{ 'open': '${', 'close': '}' },
|
||||
{ 'open': '~{', 'close': '}' },
|
||||
{ 'open': '{{', 'close': '}}' },
|
||||
{ 'open': '{{{', 'close': '}}}' }
|
||||
]
|
||||
};
|
||||
|
||||
registerTextmateLanguage(registry: TextmateRegistry): void {
|
||||
monaco.languages.register({
|
||||
id: PROMPT_TEMPLATE_LANGUAGE_ID,
|
||||
'aliases': [
|
||||
'AI Prompt Template'
|
||||
],
|
||||
'extensions': [
|
||||
PROMPT_TEMPLATE_EXTENSION,
|
||||
],
|
||||
'filenames': []
|
||||
});
|
||||
|
||||
monaco.languages.setLanguageConfiguration(PROMPT_TEMPLATE_LANGUAGE_ID, this.config);
|
||||
|
||||
monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, {
|
||||
// Monaco only supports single character trigger characters
|
||||
triggerCharacters: ['{'],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideFunctionCompletions(model, position),
|
||||
});
|
||||
|
||||
monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, {
|
||||
// Monaco only supports single character trigger characters
|
||||
triggerCharacters: ['{'],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideVariableCompletions(model, position),
|
||||
});
|
||||
monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, {
|
||||
// Monaco only supports single character trigger characters
|
||||
triggerCharacters: ['{', ':'],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> => this.provideVariableWithArgCompletions(model, position),
|
||||
});
|
||||
|
||||
const textmateGrammar = require('../../data/prompttemplate.tmLanguage.json');
|
||||
const grammarDefinitionProvider: GrammarDefinitionProvider = {
|
||||
getGrammarDefinition: function (): Promise<GrammarDefinition> {
|
||||
return Promise.resolve({
|
||||
format: 'json',
|
||||
content: textmateGrammar
|
||||
});
|
||||
}
|
||||
};
|
||||
registry.registerTextmateGrammarScope(PROMPT_TEMPLATE_TEXTMATE_SCOPE, grammarDefinitionProvider);
|
||||
|
||||
registry.mapLanguageIdToTextmateGrammar(PROMPT_TEMPLATE_LANGUAGE_ID, PROMPT_TEMPLATE_TEXTMATE_SCOPE);
|
||||
}
|
||||
|
||||
provideFunctionCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
|
||||
return this.getSuggestions(
|
||||
model,
|
||||
position,
|
||||
'~{',
|
||||
this.toolInvocationRegistry.getAllFunctions(),
|
||||
monaco.languages.CompletionItemKind.Function,
|
||||
tool => tool.id,
|
||||
tool => tool.name,
|
||||
tool => tool.description ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
provideVariableCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
|
||||
return this.getSuggestions(
|
||||
model,
|
||||
position,
|
||||
'{{',
|
||||
this.variableService.getVariables(),
|
||||
monaco.languages.CompletionItemKind.Variable,
|
||||
variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : variable.name,
|
||||
variable => variable.name,
|
||||
variable => variable.description ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise<monaco.languages.CompletionList> {
|
||||
// Get the text of the current line up to the cursor position
|
||||
const textUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
// Regex that captures the variable name in contexts like {{, {{{, {{varname, {{{varname, {{varname:, or {{{varname:
|
||||
const variableRegex = /(?:\{\{\{|\{\{)([\w-]+)?(?::)?/;
|
||||
const match = textUntilPosition.match(variableRegex);
|
||||
|
||||
if (!match) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const currentVariableName = match[1];
|
||||
const hasColonSeparator = textUntilPosition.includes(`${currentVariableName}:`);
|
||||
|
||||
const variables = this.variableService.getVariables();
|
||||
const suggestions: monaco.languages.CompletionItem[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
// If we have a variable:arg pattern, only process the matching variable
|
||||
if (hasColonSeparator && variable.name !== currentVariableName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const provider = await this.variableService.getArgumentCompletionProvider(variable.name);
|
||||
if (provider) {
|
||||
const items = await provider(model, position, '{');
|
||||
if (items) {
|
||||
suggestions.push(...items.map(item => ({
|
||||
...item
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacters: string): monaco.Range | undefined {
|
||||
// Check if the characters before the current position are the trigger characters
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
const triggerLength = triggerCharacters.length;
|
||||
const charactersBefore = lineContent.substring(
|
||||
position.column - triggerLength - 1,
|
||||
position.column - 1
|
||||
);
|
||||
|
||||
if (charactersBefore !== triggerCharacters) {
|
||||
// Do not return agent suggestions if the user didn't just type the trigger characters
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate the range from the position of the trigger characters
|
||||
const wordInfo = model.getWordUntilPosition(position);
|
||||
return new monaco.Range(
|
||||
position.lineNumber,
|
||||
wordInfo.startColumn,
|
||||
position.lineNumber,
|
||||
position.column
|
||||
);
|
||||
}
|
||||
|
||||
private getSuggestions<T>(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
triggerChars: string,
|
||||
items: T[],
|
||||
kind: monaco.languages.CompletionItemKind,
|
||||
getId: (item: T) => string,
|
||||
getName: (item: T) => string,
|
||||
getDescription: (item: T) => string
|
||||
): ProviderResult<monaco.languages.CompletionList> {
|
||||
const completionRange = this.getCompletionRange(model, position, triggerChars);
|
||||
if (completionRange === undefined) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
const suggestions = items.map(item => ({
|
||||
insertText: getId(item),
|
||||
kind: kind,
|
||||
label: getName(item),
|
||||
range: completionRange,
|
||||
detail: getDescription(item),
|
||||
}));
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS, {
|
||||
isVisible: (widget: Widget | undefined) => this.isPromptTemplateWidget(widget),
|
||||
isEnabled: (widget: Widget | undefined) => this.canDiscard(widget),
|
||||
execute: (widget: EditorWidget) => this.discard(widget)
|
||||
});
|
||||
}
|
||||
|
||||
protected isPromptTemplateWidget(widget: Widget | undefined): boolean {
|
||||
if (widget instanceof EditorWidget) {
|
||||
return PROMPT_TEMPLATE_LANGUAGE_ID === widget.editor.document.languageId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected canDiscard(widget: Widget | undefined): boolean {
|
||||
if (!(widget instanceof EditorWidget)) {
|
||||
return false;
|
||||
}
|
||||
const resourceUri = widget.editor.uri;
|
||||
const id = this.promptService.getTemplateIDFromResource(resourceUri);
|
||||
if (id === undefined) {
|
||||
return false;
|
||||
}
|
||||
const rawPrompt = this.promptService.getRawPromptFragment(id);
|
||||
const defaultPrompt = this.promptService.getBuiltInRawPrompt(id);
|
||||
return rawPrompt?.template !== defaultPrompt?.template;
|
||||
}
|
||||
|
||||
protected async discard(widget: EditorWidget): Promise<void> {
|
||||
const resourceUri = widget.editor.uri;
|
||||
const id = this.promptService.getTemplateIDFromResource(resourceUri);
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
const defaultPrompt = this.promptService.getBuiltInRawPrompt(id);
|
||||
if (defaultPrompt === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source: string = widget.editor.document.getText();
|
||||
const lastLine = widget.editor.document.getLineContent(widget.editor.document.lineCount);
|
||||
|
||||
const replaceOperation: ReplaceOperation = {
|
||||
range: {
|
||||
start: {
|
||||
line: 0,
|
||||
character: 0
|
||||
},
|
||||
end: {
|
||||
line: widget.editor.document.lineCount,
|
||||
character: lastLine.length
|
||||
}
|
||||
},
|
||||
text: defaultPrompt.template
|
||||
};
|
||||
|
||||
await widget.editor.replaceText({
|
||||
source,
|
||||
replaceOperations: [replaceOperation]
|
||||
});
|
||||
}
|
||||
|
||||
registerToolbarItems(registry: TabBarToolbarRegistry): void {
|
||||
registry.registerItem({
|
||||
id: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id,
|
||||
command: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id,
|
||||
tooltip: nls.localize('theia/ai/core/discardCustomPrompt/tooltip', 'Discard Customizations')
|
||||
});
|
||||
}
|
||||
}
|
||||
111
packages/ai-core/src/browser/prompttemplate-parser.ts
Normal file
111
packages/ai-core/src/browser/prompttemplate-parser.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { load } from 'js-yaml';
|
||||
import { CommandPromptFragmentMetadata } from '../common';
|
||||
|
||||
/**
|
||||
* Result of parsing a template file that may contain YAML front matter
|
||||
*/
|
||||
export interface ParsedTemplate {
|
||||
/** The template content (without front matter) */
|
||||
template: string;
|
||||
|
||||
/** Parsed metadata from YAML front matter, if present */
|
||||
metadata?: CommandPromptFragmentMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is valid TemplateMetadata
|
||||
*/
|
||||
export function isTemplateMetadata(obj: unknown): obj is CommandPromptFragmentMetadata {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const metadata = obj as Record<string, unknown>;
|
||||
return (
|
||||
(metadata.isCommand === undefined || typeof metadata.isCommand === 'boolean') &&
|
||||
(metadata.commandName === undefined || typeof metadata.commandName === 'string') &&
|
||||
(metadata.commandDescription === undefined || typeof metadata.commandDescription === 'string') &&
|
||||
(metadata.commandArgumentHint === undefined || typeof metadata.commandArgumentHint === 'string') &&
|
||||
(metadata.commandAgents === undefined || (Array.isArray(metadata.commandAgents) &&
|
||||
metadata.commandAgents.every(agent => typeof agent === 'string')))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a template file that may contain YAML front matter.
|
||||
*
|
||||
* Front matter format:
|
||||
* ```
|
||||
* ---
|
||||
* isCommand: true
|
||||
* commandName: mycommand
|
||||
* commandDescription: My command description
|
||||
* commandArgumentHint: <arg1> <arg2>
|
||||
* commandAgents:
|
||||
* - Agent1
|
||||
* - Agent2
|
||||
* ---
|
||||
* Template content here
|
||||
* ```
|
||||
*
|
||||
* @param fileContent The raw file content to parse
|
||||
* @returns ParsedTemplate containing the template content and optional metadata
|
||||
*/
|
||||
export function parseTemplateWithMetadata(fileContent: string): ParsedTemplate {
|
||||
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = fileContent.match(frontMatterRegex);
|
||||
|
||||
if (!match) {
|
||||
// No front matter, return content as-is
|
||||
return { template: fileContent };
|
||||
}
|
||||
|
||||
try {
|
||||
const yamlContent = match[1];
|
||||
const template = match[2];
|
||||
const parsedYaml = load(yamlContent);
|
||||
|
||||
// Validate the parsed YAML is an object
|
||||
if (!parsedYaml || typeof parsedYaml !== 'object') {
|
||||
return { template: fileContent };
|
||||
}
|
||||
|
||||
const metadata = parsedYaml as Record<string, unknown>;
|
||||
|
||||
// Extract and validate command metadata
|
||||
const templateMetadata: CommandPromptFragmentMetadata = {
|
||||
isCommand: typeof metadata.isCommand === 'boolean' ? metadata.isCommand : undefined,
|
||||
commandName: typeof metadata.commandName === 'string' ? metadata.commandName : undefined,
|
||||
commandDescription: typeof metadata.commandDescription === 'string' ? metadata.commandDescription : undefined,
|
||||
commandArgumentHint: typeof metadata.commandArgumentHint === 'string' ? metadata.commandArgumentHint : undefined,
|
||||
commandAgents: Array.isArray(metadata.commandAgents) ? metadata.commandAgents.filter(a => typeof a === 'string') : undefined,
|
||||
};
|
||||
|
||||
// Only include metadata if it's valid
|
||||
if (isTemplateMetadata(templateMetadata)) {
|
||||
return { template, metadata: templateMetadata };
|
||||
}
|
||||
|
||||
// Metadata validation failed, return just the template
|
||||
return { template };
|
||||
} catch (error) {
|
||||
console.error('Failed to parse front matter:', error);
|
||||
// Return entire content if YAML parsing fails
|
||||
return { template: fileContent };
|
||||
}
|
||||
}
|
||||
69
packages/ai-core/src/browser/skill-prompt-coordinator.ts
Normal file
69
packages/ai-core/src/browser/skill-prompt-coordinator.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { SkillService } from './skill-service';
|
||||
import { PromptService } from '../common/prompt-service';
|
||||
|
||||
@injectable()
|
||||
export class SkillPromptCoordinator implements FrontendApplicationContribution {
|
||||
|
||||
@inject(SkillService)
|
||||
protected readonly skillService: SkillService;
|
||||
|
||||
@inject(PromptService)
|
||||
protected readonly promptService: PromptService;
|
||||
|
||||
protected registeredSkillCommands = new Set<string>();
|
||||
|
||||
onStart(): void {
|
||||
// Register initial skills
|
||||
this.updateSkillCommands();
|
||||
|
||||
// Listen for skill changes
|
||||
this.skillService.onSkillsChanged(() => {
|
||||
this.updateSkillCommands();
|
||||
});
|
||||
}
|
||||
|
||||
protected updateSkillCommands(): void {
|
||||
const currentSkills = this.skillService.getSkills();
|
||||
const currentSkillNames = new Set(currentSkills.map(s => s.name));
|
||||
|
||||
// Unregister removed skills
|
||||
for (const name of this.registeredSkillCommands) {
|
||||
if (!currentSkillNames.has(name)) {
|
||||
this.promptService.removePromptFragment(`skill-command-${name}`);
|
||||
this.registeredSkillCommands.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Register new skills
|
||||
for (const skill of currentSkills) {
|
||||
if (!this.registeredSkillCommands.has(skill.name)) {
|
||||
this.promptService.addBuiltInPromptFragment({
|
||||
id: `skill-command-${skill.name}`,
|
||||
template: `{{skill:${skill.name}}}`,
|
||||
isCommand: true,
|
||||
commandName: skill.name,
|
||||
commandDescription: skill.description
|
||||
});
|
||||
this.registeredSkillCommands.add(skill.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
481
packages/ai-core/src/browser/skill-service.spec.ts
Normal file
481
packages/ai-core/src/browser/skill-service.spec.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
const disableJSDOM = enableJSDOM();
|
||||
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { parseSkillFile, combineSkillDirectories } from '../common/skill';
|
||||
import { Path } from '@theia/core/lib/common/path';
|
||||
import { Disposable, Emitter, ILogger, Logger, URI } from '@theia/core';
|
||||
import { FileChangesEvent } from '@theia/filesystem/lib/common/files';
|
||||
import { DefaultSkillService } from './skill-service';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('SkillService', () => {
|
||||
describe('tilde expansion', () => {
|
||||
it('should expand ~ to home directory in configured paths', () => {
|
||||
const homePath = '/home/testuser';
|
||||
const configuredDirectories = ['~/skills', '~/.theia/skills', '/absolute/path'];
|
||||
|
||||
const expanded = configuredDirectories.map(dir => Path.untildify(dir, homePath));
|
||||
|
||||
expect(expanded).to.deep.equal([
|
||||
'/home/testuser/skills',
|
||||
'/home/testuser/.theia/skills',
|
||||
'/absolute/path'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty home path gracefully', () => {
|
||||
const configuredDirectories = ['~/skills'];
|
||||
const expanded = configuredDirectories.map(dir => Path.untildify(dir, ''));
|
||||
|
||||
// With empty home, tilde is not expanded
|
||||
expect(expanded).to.deep.equal(['~/skills']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory prioritization', () => {
|
||||
it('workspace directory comes first when all directories provided', () => {
|
||||
const result = combineSkillDirectories(
|
||||
'/workspace/.prompts/skills',
|
||||
['/custom/skills1', '/custom/skills2'],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/workspace/.prompts/skills',
|
||||
'/custom/skills1',
|
||||
'/custom/skills2',
|
||||
'/home/user/.theia/skills'
|
||||
]);
|
||||
});
|
||||
|
||||
it('works without workspace directory', () => {
|
||||
const result = combineSkillDirectories(
|
||||
undefined,
|
||||
['/custom/skills'],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/custom/skills',
|
||||
'/home/user/.theia/skills'
|
||||
]);
|
||||
});
|
||||
|
||||
it('works with only default directory', () => {
|
||||
const result = combineSkillDirectories(
|
||||
undefined,
|
||||
[],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal(['/home/user/.theia/skills']);
|
||||
});
|
||||
|
||||
it('deduplicates workspace directory if also in configured', () => {
|
||||
const result = combineSkillDirectories(
|
||||
'/workspace/.prompts/skills',
|
||||
['/workspace/.prompts/skills', '/custom/skills'],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/workspace/.prompts/skills',
|
||||
'/custom/skills',
|
||||
'/home/user/.theia/skills'
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates default directory if also in configured', () => {
|
||||
const result = combineSkillDirectories(
|
||||
'/workspace/.prompts/skills',
|
||||
['/home/user/.theia/skills'],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/workspace/.prompts/skills',
|
||||
'/home/user/.theia/skills'
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty configured directories', () => {
|
||||
const result = combineSkillDirectories(
|
||||
'/workspace/.prompts/skills',
|
||||
[],
|
||||
'/home/user/.theia/skills'
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/workspace/.prompts/skills',
|
||||
'/home/user/.theia/skills'
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles undefined default directory', () => {
|
||||
const result = combineSkillDirectories(
|
||||
'/workspace/.prompts/skills',
|
||||
['/custom/skills'],
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(result).to.deep.equal([
|
||||
'/workspace/.prompts/skills',
|
||||
'/custom/skills'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSkillFile', () => {
|
||||
it('extracts YAML front matter correctly', () => {
|
||||
const fileContent = `---
|
||||
name: my-skill
|
||||
description: A test skill for testing purposes
|
||||
license: MIT
|
||||
compatibility: ">=1.0.0"
|
||||
metadata:
|
||||
author: test
|
||||
version: "1.0.0"
|
||||
---
|
||||
# My Skill
|
||||
|
||||
This is the skill content.`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.equal(`# My Skill
|
||||
|
||||
This is the skill content.`);
|
||||
expect(result.metadata).to.not.be.undefined;
|
||||
expect(result.metadata?.name).to.equal('my-skill');
|
||||
expect(result.metadata?.description).to.equal('A test skill for testing purposes');
|
||||
expect(result.metadata?.license).to.equal('MIT');
|
||||
expect(result.metadata?.compatibility).to.equal('>=1.0.0');
|
||||
expect(result.metadata?.metadata).to.deep.equal({ author: 'test', version: '1.0.0' });
|
||||
});
|
||||
|
||||
it('returns content without metadata when no front matter exists', () => {
|
||||
const fileContent = '# Just a regular markdown file';
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.equal('# Just a regular markdown file');
|
||||
expect(result.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('handles missing front matter gracefully', () => {
|
||||
const fileContent = `---
|
||||
This is not valid YAML front matter
|
||||
Skill content`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.equal(fileContent);
|
||||
expect(result.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('handles invalid YAML gracefully', () => {
|
||||
const fileContent = `---
|
||||
name: my-skill
|
||||
description: [unclosed array
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.equal(fileContent);
|
||||
expect(result.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('handles minimal required fields', () => {
|
||||
const fileContent = `---
|
||||
name: minimal-skill
|
||||
description: A minimal skill
|
||||
---
|
||||
Content`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.equal('Content');
|
||||
expect(result.metadata?.name).to.equal('minimal-skill');
|
||||
expect(result.metadata?.description).to.equal('A minimal skill');
|
||||
expect(result.metadata?.license).to.be.undefined;
|
||||
expect(result.metadata?.compatibility).to.be.undefined;
|
||||
expect(result.metadata?.metadata).to.be.undefined;
|
||||
});
|
||||
|
||||
it('handles allowedTools field', () => {
|
||||
const fileContent = `---
|
||||
name: tool-skill
|
||||
description: A skill with allowed tools
|
||||
allowedTools:
|
||||
- tool1
|
||||
- tool2
|
||||
---
|
||||
Content`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.metadata?.allowedTools).to.deep.equal(['tool1', 'tool2']);
|
||||
});
|
||||
|
||||
it('preserves markdown content with special characters', () => {
|
||||
const fileContent = `---
|
||||
name: special-skill
|
||||
description: Test
|
||||
---
|
||||
# Skill with {{variable}} and \`code\` and **bold**
|
||||
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.content).to.contain('{{variable}}');
|
||||
expect(result.content).to.contain('`code`');
|
||||
expect(result.content).to.contain('**bold**');
|
||||
expect(result.content).to.contain('const x = 1;');
|
||||
});
|
||||
|
||||
it('handles empty content after front matter', () => {
|
||||
const fileContent = `---
|
||||
name: empty-content
|
||||
description: Skill with no content
|
||||
---
|
||||
`;
|
||||
|
||||
const result = parseSkillFile(fileContent);
|
||||
|
||||
expect(result.metadata?.name).to.equal('empty-content');
|
||||
expect(result.content).to.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent directory watching', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let fileServiceMock: any;
|
||||
let loggerWarnSpy: sinon.SinonStub;
|
||||
let loggerInfoSpy: sinon.SinonStub;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let envVariablesServerMock: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let workspaceServiceMock: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let preferencesMock: any;
|
||||
let fileChangesEmitter: Emitter<FileChangesEvent>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let preferenceChangedEmitter: Emitter<any>;
|
||||
|
||||
function createService(): DefaultSkillService {
|
||||
const service = new DefaultSkillService();
|
||||
(service as unknown as { preferences: unknown }).preferences = preferencesMock;
|
||||
(service as unknown as { fileService: unknown }).fileService = fileServiceMock;
|
||||
const loggerMock: ILogger = sinon.createStubInstance(Logger);
|
||||
loggerMock.warn = loggerWarnSpy;
|
||||
loggerMock.info = loggerInfoSpy;
|
||||
(service as unknown as { logger: unknown }).logger = loggerMock;
|
||||
(service as unknown as { envVariablesServer: unknown }).envVariablesServer = envVariablesServerMock;
|
||||
(service as unknown as { workspaceService: unknown }).workspaceService = workspaceServiceMock;
|
||||
return service;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fileChangesEmitter = new Emitter<FileChangesEvent>();
|
||||
preferenceChangedEmitter = new Emitter();
|
||||
|
||||
fileServiceMock = {
|
||||
exists: sinon.stub(),
|
||||
watch: sinon.stub().returns(Disposable.NULL),
|
||||
resolve: sinon.stub(),
|
||||
read: sinon.stub(),
|
||||
onDidFilesChange: (listener: (e: FileChangesEvent) => void) => fileChangesEmitter.event(listener)
|
||||
};
|
||||
|
||||
loggerWarnSpy = sinon.stub();
|
||||
loggerInfoSpy = sinon.stub();
|
||||
|
||||
envVariablesServerMock = {
|
||||
getHomeDirUri: sinon.stub().resolves('file:///home/testuser'),
|
||||
getConfigDirUri: sinon.stub().resolves('file:///home/testuser/.theia-ide')
|
||||
};
|
||||
|
||||
workspaceServiceMock = {
|
||||
ready: Promise.resolve(),
|
||||
tryGetRoots: sinon.stub().returns([]),
|
||||
onWorkspaceChanged: sinon.stub().returns(Disposable.NULL)
|
||||
};
|
||||
|
||||
preferencesMock = {
|
||||
'ai-features.skills.skillDirectories': [],
|
||||
onPreferenceChanged: preferenceChangedEmitter.event
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
fileChangesEmitter.dispose();
|
||||
preferenceChangedEmitter.dispose();
|
||||
});
|
||||
|
||||
it('should watch parent directory when skills directory does not exist', async () => {
|
||||
const service = createService();
|
||||
|
||||
// Default skills directory does not exist, but parent does
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves(false);
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide'))
|
||||
.resolves(true);
|
||||
|
||||
// Call init to trigger update
|
||||
(service as unknown as { init: () => void }).init();
|
||||
await workspaceServiceMock.ready;
|
||||
// Allow async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify parent directory is watched
|
||||
expect(fileServiceMock.watch.calledWith(
|
||||
sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide'),
|
||||
sinon.match({ recursive: false, excludes: [] })
|
||||
)).to.be.true;
|
||||
|
||||
// Verify info log about watching parent
|
||||
expect(loggerInfoSpy.calledWith(
|
||||
sinon.match(/Watching parent directory.*for skills folder creation/)
|
||||
)).to.be.true;
|
||||
});
|
||||
|
||||
it('should log warning when parent directory does not exist', async () => {
|
||||
const service = createService();
|
||||
|
||||
// Neither skills directory nor parent exists
|
||||
fileServiceMock.exists.resolves(false);
|
||||
|
||||
// Call init to trigger update
|
||||
(service as unknown as { init: () => void }).init();
|
||||
await workspaceServiceMock.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify warning is logged about parent not existing
|
||||
expect(loggerWarnSpy.calledWith(
|
||||
sinon.match(/Cannot watch skills directory.*parent directory does not exist/)
|
||||
)).to.be.true;
|
||||
});
|
||||
|
||||
it('should log warning for non-existent configured directories', async () => {
|
||||
const service = createService();
|
||||
|
||||
// Set up configured directory that doesn't exist
|
||||
(preferencesMock as Record<string, unknown>)['ai-features.skills.skillDirectories'] = ['/custom/nonexistent/skills'];
|
||||
|
||||
// Default skills directory exists (to avoid additional warnings)
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves(true);
|
||||
fileServiceMock.resolve
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves({ children: [] });
|
||||
|
||||
// Configured directory does not exist
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/custom/nonexistent/skills'))
|
||||
.resolves(false);
|
||||
|
||||
// Call init to trigger update
|
||||
(service as unknown as { init: () => void }).init();
|
||||
await workspaceServiceMock.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify warning is logged for non-existent configured directory
|
||||
expect(loggerWarnSpy.calledWith(
|
||||
sinon.match(/Configured skill directory.*does not exist/)
|
||||
)).to.be.true;
|
||||
});
|
||||
|
||||
it('should load skills when directory is created after initialization', async () => {
|
||||
const service = createService();
|
||||
|
||||
// Initially, skills directory does not exist but parent does
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves(false);
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide'))
|
||||
.resolves(true);
|
||||
|
||||
// Call init to trigger initial update
|
||||
(service as unknown as { init: () => void }).init();
|
||||
await workspaceServiceMock.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify no skills initially
|
||||
expect(service.getSkills()).to.have.length(0);
|
||||
|
||||
// Now simulate skills directory being created with a skill
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves(true);
|
||||
fileServiceMock.resolve
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills'))
|
||||
.resolves({
|
||||
children: [{
|
||||
isDirectory: true,
|
||||
name: 'test-skill',
|
||||
resource: URI.fromFilePath('/home/testuser/.theia-ide/skills/test-skill')
|
||||
}]
|
||||
});
|
||||
fileServiceMock.exists
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills/test-skill/SKILL.md'))
|
||||
.resolves(true);
|
||||
fileServiceMock.read
|
||||
.withArgs(sinon.match((uri: URI) => uri.path.toString() === '/home/testuser/.theia-ide/skills/test-skill/SKILL.md'))
|
||||
.resolves({
|
||||
value: `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
Test skill content`
|
||||
});
|
||||
|
||||
// Simulate file change event for skills directory creation
|
||||
fileChangesEmitter.fire({
|
||||
changes: [{
|
||||
type: 1, // FileChangeType.ADDED
|
||||
resource: URI.fromFilePath('/home/testuser/.theia-ide/skills')
|
||||
}],
|
||||
rawChanges: []
|
||||
} as unknown as FileChangesEvent);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify skill was loaded
|
||||
const skills = service.getSkills();
|
||||
expect(skills).to.have.length(1);
|
||||
expect(skills[0].name).to.equal('test-skill');
|
||||
});
|
||||
});
|
||||
});
|
||||
357
packages/ai-core/src/browser/skill-service.ts
Normal file
357
packages/ai-core/src/browser/skill-service.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { DisposableCollection, Emitter, Event, ILogger, URI } from '@theia/core';
|
||||
import { Path } from '@theia/core/lib/common/path';
|
||||
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { FileChangesEvent, FileChangeType } from '@theia/filesystem/lib/common/files';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { AICorePreferences, PREFERENCE_NAME_SKILL_DIRECTORIES } from '../common/ai-core-preferences';
|
||||
import { Skill, SkillDescription, SKILL_FILE_NAME, validateSkillDescription, parseSkillFile } from '../common/skill';
|
||||
|
||||
/** Debounce delay for coalescing rapid file system events */
|
||||
const UPDATE_DEBOUNCE_MS = 50;
|
||||
|
||||
export const SkillService = Symbol('SkillService');
|
||||
export interface SkillService {
|
||||
/** Get all discovered skills */
|
||||
getSkills(): Skill[];
|
||||
|
||||
/** Get a skill by name */
|
||||
getSkill(name: string): Skill | undefined;
|
||||
|
||||
/** Event fired when skills change */
|
||||
readonly onSkillsChanged: Event<void>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class DefaultSkillService implements SkillService {
|
||||
@inject(AICorePreferences)
|
||||
protected readonly preferences: AICorePreferences;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(ILogger)
|
||||
@named('SkillService')
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(EnvVariablesServer)
|
||||
protected readonly envVariablesServer: EnvVariablesServer;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
protected skills = new Map<string, Skill>();
|
||||
protected toDispose = new DisposableCollection();
|
||||
protected watchedDirectories = new Set<string>();
|
||||
protected parentWatchers = new Map<string, string>();
|
||||
|
||||
protected readonly onSkillsChangedEmitter = new Emitter<void>();
|
||||
readonly onSkillsChanged: Event<void> = this.onSkillsChangedEmitter.event;
|
||||
protected lastSkillDirectoriesValue: string | undefined;
|
||||
|
||||
protected updateDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.fileService.onDidFilesChange(async (event: FileChangesEvent) => {
|
||||
for (const change of event.changes) {
|
||||
if (change.type === FileChangeType.ADDED) {
|
||||
const changeUri = change.resource.toString();
|
||||
for (const [, skillsPath] of this.parentWatchers) {
|
||||
const expectedSkillsUri = URI.fromFilePath(skillsPath).toString();
|
||||
if (changeUri === expectedSkillsUri) {
|
||||
this.scheduleUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check for skills directory deletion - switch back to parent watching
|
||||
if (change.type === FileChangeType.DELETED) {
|
||||
const changeUri = change.resource.toString();
|
||||
if (this.watchedDirectories.has(changeUri)) {
|
||||
this.scheduleUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isRelevantChange = event.changes.some(change => {
|
||||
const changeUri = change.resource.toString();
|
||||
const isInWatchedDir = Array.from(this.watchedDirectories).some(dirUri =>
|
||||
changeUri.startsWith(dirUri)
|
||||
);
|
||||
if (!isInWatchedDir) {
|
||||
return false;
|
||||
}
|
||||
// Trigger on SKILL.md changes or directory additions/deletions
|
||||
const isSkillFile = change.resource.path.base === SKILL_FILE_NAME;
|
||||
const isDirectoryChange = change.type === FileChangeType.ADDED || change.type === FileChangeType.DELETED;
|
||||
return isSkillFile || isDirectoryChange;
|
||||
});
|
||||
if (isRelevantChange) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for workspace to be ready before initial update
|
||||
this.workspaceService.ready.then(() => {
|
||||
this.update().then(() => {
|
||||
// Only after initial update, start listening for changes
|
||||
this.lastSkillDirectoriesValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
|
||||
|
||||
this.preferences.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === PREFERENCE_NAME_SKILL_DIRECTORIES) {
|
||||
const currentValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
|
||||
if (currentValue === this.lastSkillDirectoriesValue) {
|
||||
return;
|
||||
}
|
||||
this.lastSkillDirectoriesValue = currentValue;
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
this.workspaceService.onWorkspaceChanged(() => {
|
||||
this.scheduleUpdate();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSkills(): Skill[] {
|
||||
return Array.from(this.skills.values());
|
||||
}
|
||||
|
||||
getSkill(name: string): Skill | undefined {
|
||||
return this.skills.get(name);
|
||||
}
|
||||
|
||||
protected scheduleUpdate(): void {
|
||||
if (this.updateDebounceTimeout) {
|
||||
clearTimeout(this.updateDebounceTimeout);
|
||||
}
|
||||
this.updateDebounceTimeout = setTimeout(() => {
|
||||
this.updateDebounceTimeout = undefined;
|
||||
this.update();
|
||||
}, UPDATE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
protected async update(): Promise<void> {
|
||||
if (this.updateDebounceTimeout) {
|
||||
clearTimeout(this.updateDebounceTimeout);
|
||||
this.updateDebounceTimeout = undefined;
|
||||
}
|
||||
this.toDispose.dispose();
|
||||
const newDisposables = new DisposableCollection();
|
||||
const newSkills = new Map<string, Skill>();
|
||||
|
||||
const workspaceSkillsDir = this.getWorkspaceSkillsDirectoryPath();
|
||||
|
||||
const homeDirUri = await this.envVariablesServer.getHomeDirUri();
|
||||
const homePath = new URI(homeDirUri).path.fsPath();
|
||||
|
||||
const configuredDirectories = (this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES] ?? [])
|
||||
.map(dir => Path.untildify(dir, homePath));
|
||||
const defaultSkillsDir = await this.getDefaultSkillsDirectoryPath();
|
||||
|
||||
const newWatchedDirectories = new Set<string>();
|
||||
const newParentWatchers = new Map<string, string>();
|
||||
|
||||
if (workspaceSkillsDir) {
|
||||
await this.processSkillDirectoryWithParentWatching(
|
||||
workspaceSkillsDir,
|
||||
newSkills,
|
||||
newDisposables,
|
||||
newWatchedDirectories,
|
||||
newParentWatchers
|
||||
);
|
||||
}
|
||||
|
||||
for (const configuredDir of configuredDirectories) {
|
||||
const configuredDirUri = URI.fromFilePath(configuredDir).toString();
|
||||
if (!newWatchedDirectories.has(configuredDirUri)) {
|
||||
await this.processConfiguredSkillDirectory(configuredDir, newSkills, newDisposables, newWatchedDirectories);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSkillsDirUri = URI.fromFilePath(defaultSkillsDir).toString();
|
||||
if (!newWatchedDirectories.has(defaultSkillsDirUri)) {
|
||||
await this.processSkillDirectoryWithParentWatching(
|
||||
defaultSkillsDir,
|
||||
newSkills,
|
||||
newDisposables,
|
||||
newWatchedDirectories,
|
||||
newParentWatchers
|
||||
);
|
||||
}
|
||||
|
||||
if (newSkills.size > 0 && newSkills.size !== this.skills.size) {
|
||||
this.logger.info(`Loaded ${newSkills.size} skills`);
|
||||
}
|
||||
|
||||
this.toDispose = newDisposables;
|
||||
this.skills = newSkills;
|
||||
this.watchedDirectories = newWatchedDirectories;
|
||||
this.parentWatchers = newParentWatchers;
|
||||
|
||||
this.onSkillsChangedEmitter.fire();
|
||||
}
|
||||
|
||||
protected getWorkspaceSkillsDirectoryPath(): string | undefined {
|
||||
const roots = this.workspaceService.tryGetRoots();
|
||||
if (roots.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Use primary workspace root
|
||||
return roots[0].resource.resolve('.prompts/skills').path.fsPath();
|
||||
}
|
||||
|
||||
protected async getDefaultSkillsDirectoryPath(): Promise<string> {
|
||||
const configDirUri = await this.envVariablesServer.getConfigDirUri();
|
||||
const configDir = new URI(configDirUri);
|
||||
return configDir.resolve('skills').path.fsPath();
|
||||
}
|
||||
|
||||
protected async processSkillDirectoryWithParentWatching(
|
||||
directoryPath: string,
|
||||
skills: Map<string, Skill>,
|
||||
disposables: DisposableCollection,
|
||||
watchedDirectories: Set<string>,
|
||||
parentWatchers: Map<string, string>
|
||||
): Promise<void> {
|
||||
const dirURI = URI.fromFilePath(directoryPath);
|
||||
|
||||
try {
|
||||
const dirExists = await this.fileService.exists(dirURI);
|
||||
|
||||
if (dirExists) {
|
||||
await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories);
|
||||
} else {
|
||||
const parentPath = dirURI.parent.path.fsPath();
|
||||
const parentURI = URI.fromFilePath(parentPath);
|
||||
const parentExists = await this.fileService.exists(parentURI);
|
||||
|
||||
if (parentExists) {
|
||||
const parentUriString = parentURI.toString();
|
||||
disposables.push(this.fileService.watch(parentURI, { recursive: false, excludes: [] }));
|
||||
parentWatchers.set(parentUriString, directoryPath);
|
||||
this.logger.info(`Watching parent directory '${parentPath}' for skills folder creation`);
|
||||
} else {
|
||||
this.logger.warn(`Cannot watch skills directory '${directoryPath}': parent directory does not exist`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing directory '${directoryPath}': ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async processConfiguredSkillDirectory(
|
||||
directoryPath: string,
|
||||
skills: Map<string, Skill>,
|
||||
disposables: DisposableCollection,
|
||||
watchedDirectories: Set<string>
|
||||
): Promise<void> {
|
||||
const dirURI = URI.fromFilePath(directoryPath);
|
||||
|
||||
try {
|
||||
const dirExists = await this.fileService.exists(dirURI);
|
||||
|
||||
if (!dirExists) {
|
||||
this.logger.warn(`Configured skill directory '${directoryPath}' does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing configured directory '${directoryPath}': ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async processExistingSkillDirectory(
|
||||
dirURI: URI,
|
||||
skills: Map<string, Skill>,
|
||||
disposables: DisposableCollection,
|
||||
watchedDirectories: Set<string>
|
||||
): Promise<void> {
|
||||
const stat = await this.fileService.resolve(dirURI);
|
||||
if (!stat.children) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of stat.children) {
|
||||
if (child.isDirectory) {
|
||||
const directoryName = child.name;
|
||||
await this.loadSkillFromDirectory(child.resource, directoryName, skills);
|
||||
}
|
||||
}
|
||||
|
||||
this.setupDirectoryWatcher(dirURI, disposables, watchedDirectories);
|
||||
}
|
||||
|
||||
protected async loadSkillFromDirectory(directoryUri: URI, directoryName: string, skills: Map<string, Skill>): Promise<void> {
|
||||
const skillFileUri = directoryUri.resolve(SKILL_FILE_NAME);
|
||||
|
||||
const fileExists = await this.fileService.exists(skillFileUri);
|
||||
if (!fileExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await this.fileService.read(skillFileUri);
|
||||
const parsed = parseSkillFile(fileContent.value);
|
||||
|
||||
if (!parsed.metadata) {
|
||||
this.logger.warn(`Skill in '${directoryName}': SKILL.md file has no valid YAML frontmatter`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SkillDescription.is(parsed.metadata)) {
|
||||
this.logger.warn(`Skill in '${directoryName}': Invalid skill description - missing required fields (name, description)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationErrors = validateSkillDescription(parsed.metadata, directoryName);
|
||||
if (validationErrors.length > 0) {
|
||||
this.logger.warn(`Skill in '${directoryName}': ${validationErrors.join('; ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillName = parsed.metadata.name;
|
||||
|
||||
if (skills.has(skillName)) {
|
||||
this.logger.warn(`Skill '${skillName}': Duplicate skill found in '${directoryName}', using first discovered instance`);
|
||||
return;
|
||||
}
|
||||
|
||||
const skill: Skill = {
|
||||
...parsed.metadata,
|
||||
location: skillFileUri.path.fsPath()
|
||||
};
|
||||
|
||||
skills.set(skillName, skill);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load skill from '${directoryName}': ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected setupDirectoryWatcher(dirURI: URI, disposables: DisposableCollection, watchedDirectories: Set<string>): void {
|
||||
disposables.push(this.fileService.watch(dirURI, { recursive: true, excludes: [] }));
|
||||
watchedDirectories.add(dirURI.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
import { ILogger } from '@theia/core';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { SkillsVariableContribution, SKILLS_VARIABLE, SKILL_VARIABLE, ResolvedSkillsVariable } from './skills-variable-contribution';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { SkillService } from './skill-service';
|
||||
import { Skill } from '../common/skill';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('SkillsVariableContribution', () => {
|
||||
let contribution: SkillsVariableContribution;
|
||||
let skillService: sinon.SinonStubbedInstance<SkillService>;
|
||||
let mockFileService: { read: sinon.SinonStub, exists: sinon.SinonStub };
|
||||
let container: Container;
|
||||
|
||||
before(() => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
|
||||
skillService = {
|
||||
getSkills: sinon.stub(),
|
||||
getSkill: sinon.stub(),
|
||||
onSkillsChanged: sinon.stub() as unknown as typeof skillService.onSkillsChanged
|
||||
};
|
||||
|
||||
container.bind(SkillService).toConstantValue(skillService as unknown as SkillService);
|
||||
|
||||
mockFileService = {
|
||||
read: sinon.stub(),
|
||||
exists: sinon.stub(),
|
||||
};
|
||||
container.bind(FileService).toConstantValue(mockFileService as unknown as FileService);
|
||||
|
||||
const mockLogger = {
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
debug: sinon.stub(),
|
||||
trace: sinon.stub(),
|
||||
fatal: sinon.stub(),
|
||||
log: sinon.stub(),
|
||||
setLogLevel: sinon.stub(),
|
||||
getLogLevel: sinon.stub(),
|
||||
isEnabled: sinon.stub().returns(true),
|
||||
ifEnabled: sinon.stub(),
|
||||
child: sinon.stub()
|
||||
};
|
||||
container.bind(ILogger).toConstantValue(mockLogger as unknown as ILogger);
|
||||
|
||||
container.bind(SkillsVariableContribution).toSelf().inSingletonScope();
|
||||
|
||||
contribution = container.get(SkillsVariableContribution);
|
||||
});
|
||||
|
||||
describe('SKILLS_VARIABLE', () => {
|
||||
it('should have correct id and name', () => {
|
||||
expect(SKILLS_VARIABLE.id).to.equal('skills');
|
||||
expect(SKILLS_VARIABLE.name).to.equal('skills');
|
||||
});
|
||||
|
||||
it('should have a description', () => {
|
||||
expect(SKILLS_VARIABLE.description).to.be.a('string');
|
||||
expect(SKILLS_VARIABLE.description.length).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SKILL_VARIABLE', () => {
|
||||
it('should have correct id and name', () => {
|
||||
expect(SKILL_VARIABLE.id).to.equal('skill');
|
||||
expect(SKILL_VARIABLE.name).to.equal('skill');
|
||||
});
|
||||
|
||||
it('should have args defined', () => {
|
||||
expect(SKILL_VARIABLE.args).to.not.be.undefined;
|
||||
expect(SKILL_VARIABLE.args).to.have.lengthOf(1);
|
||||
expect(SKILL_VARIABLE.args![0].name).to.equal('skillName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canResolve', () => {
|
||||
it('should return 1 for skills variable', () => {
|
||||
const result = contribution.canResolve(
|
||||
{ variable: SKILLS_VARIABLE },
|
||||
{}
|
||||
);
|
||||
expect(result).to.equal(1);
|
||||
});
|
||||
|
||||
it('should return 1 for skill variable', () => {
|
||||
const result = contribution.canResolve(
|
||||
{ variable: SKILL_VARIABLE },
|
||||
{}
|
||||
);
|
||||
expect(result).to.equal(1);
|
||||
});
|
||||
|
||||
it('should return -1 for other variables', () => {
|
||||
const result = contribution.canResolve(
|
||||
{ variable: { id: 'other', name: 'other', description: 'other' } },
|
||||
{}
|
||||
);
|
||||
expect(result).to.equal(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should return undefined for non-skills variable', async () => {
|
||||
const result = await contribution.resolve(
|
||||
{ variable: { id: 'other', name: 'other', description: 'other' } },
|
||||
{}
|
||||
);
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should return empty XML when no skills available', async () => {
|
||||
skillService.getSkills.returns([]);
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILLS_VARIABLE },
|
||||
{}
|
||||
) as ResolvedSkillsVariable;
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result.variable).to.equal(SKILLS_VARIABLE);
|
||||
expect(result.skills).to.deep.equal([]);
|
||||
expect(result.value).to.equal('<available_skills>\n</available_skills>');
|
||||
});
|
||||
|
||||
it('should return XML with skills when available', async () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: 'pdf-processing',
|
||||
description: 'Processes PDF documents and extracts text content',
|
||||
location: '/path/to/skills/pdf-processing/SKILL.md'
|
||||
},
|
||||
{
|
||||
name: 'data-analysis',
|
||||
description: 'Analyzes data sets and generates reports',
|
||||
location: '/path/to/skills/data-analysis/SKILL.md'
|
||||
}
|
||||
];
|
||||
skillService.getSkills.returns(skills);
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILLS_VARIABLE },
|
||||
{}
|
||||
) as ResolvedSkillsVariable;
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result.variable).to.equal(SKILLS_VARIABLE);
|
||||
expect(result.skills).to.have.lengthOf(2);
|
||||
expect(result.skills[0].name).to.equal('pdf-processing');
|
||||
expect(result.skills[0].location).to.equal('/path/to/skills/pdf-processing/SKILL.md');
|
||||
expect(result.skills[1].name).to.equal('data-analysis');
|
||||
expect(result.skills[1].location).to.equal('/path/to/skills/data-analysis/SKILL.md');
|
||||
|
||||
const expectedXml =
|
||||
'<available_skills>\n' +
|
||||
'<skill>\n' +
|
||||
'<name>pdf-processing</name>\n' +
|
||||
'<description>Processes PDF documents and extracts text content</description>\n' +
|
||||
'<location>/path/to/skills/pdf-processing/SKILL.md</location>\n' +
|
||||
'</skill>\n' +
|
||||
'<skill>\n' +
|
||||
'<name>data-analysis</name>\n' +
|
||||
'<description>Analyzes data sets and generates reports</description>\n' +
|
||||
'<location>/path/to/skills/data-analysis/SKILL.md</location>\n' +
|
||||
'</skill>\n' +
|
||||
'</available_skills>';
|
||||
expect(result.value).to.equal(expectedXml);
|
||||
});
|
||||
|
||||
it('should escape XML special characters in descriptions', async () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'Handles <tags> & "quotes" with \'apostrophes\'',
|
||||
location: '/path/to/skill/SKILL.md'
|
||||
}
|
||||
];
|
||||
skillService.getSkills.returns(skills);
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILLS_VARIABLE },
|
||||
{}
|
||||
) as ResolvedSkillsVariable;
|
||||
|
||||
expect(result.value).to.include('<tags>');
|
||||
expect(result.value).to.include('&');
|
||||
expect(result.value).to.include('"quotes"');
|
||||
expect(result.value).to.include(''apostrophes'');
|
||||
});
|
||||
|
||||
it('should escape XML special characters in name and location', async () => {
|
||||
const skills: Skill[] = [
|
||||
{
|
||||
name: 'skill<test>',
|
||||
description: 'Test skill',
|
||||
location: '/path/with/&special/chars'
|
||||
}
|
||||
];
|
||||
skillService.getSkills.returns(skills);
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILLS_VARIABLE },
|
||||
{}
|
||||
) as ResolvedSkillsVariable;
|
||||
|
||||
expect(result.value).to.include('<name>skill<test></name>');
|
||||
expect(result.value).to.include('<location>/path/with/&special/chars</location>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve single skill', () => {
|
||||
it('should return undefined when no arg provided', async () => {
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILL_VARIABLE },
|
||||
{}
|
||||
);
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should return undefined when skill not found', async () => {
|
||||
skillService.getSkill.returns(undefined);
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILL_VARIABLE, arg: 'non-existent' },
|
||||
{}
|
||||
);
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should return skill content when skill found', async () => {
|
||||
const skill: Skill = {
|
||||
name: 'my-skill',
|
||||
description: 'A test skill',
|
||||
location: '/path/to/skills/my-skill/SKILL.md'
|
||||
};
|
||||
skillService.getSkill.withArgs('my-skill').returns(skill);
|
||||
mockFileService.read.resolves({
|
||||
value: `---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
# My Skill Content
|
||||
|
||||
This is the skill content.`
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILL_VARIABLE, arg: 'my-skill' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.equal(SKILL_VARIABLE);
|
||||
expect(result!.value).to.equal('# My Skill Content\n\nThis is the skill content.');
|
||||
});
|
||||
|
||||
it('should return undefined when file read fails', async () => {
|
||||
const skill: Skill = {
|
||||
name: 'my-skill',
|
||||
description: 'A test skill',
|
||||
location: '/path/to/skills/my-skill/SKILL.md'
|
||||
};
|
||||
skillService.getSkill.withArgs('my-skill').returns(skill);
|
||||
mockFileService.read.rejects(new Error('File not found'));
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: SKILL_VARIABLE, arg: 'my-skill' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/ai-core/src/browser/skills-variable-contribution.ts
Normal file
157
packages/ai-core/src/browser/skills-variable-contribution.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ILogger, MaybePromise, nls, URI } from '@theia/core';
|
||||
import {
|
||||
AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest,
|
||||
AIVariableResolver, AIVariableService, ResolvedAIVariable
|
||||
} from '../common/variable-service';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { SkillService } from './skill-service';
|
||||
import { parseSkillFile } from '../common/skill';
|
||||
|
||||
export const SKILLS_VARIABLE: AIVariable = {
|
||||
id: 'skills',
|
||||
name: 'skills',
|
||||
description: nls.localize('theia/ai/core/skillsVariable/description',
|
||||
'Returns the list of available skills that can be used by AI agents')
|
||||
};
|
||||
|
||||
export const SKILL_VARIABLE: AIVariable = {
|
||||
id: 'skill',
|
||||
name: 'skill',
|
||||
description: 'Returns the content of a specific skill by name',
|
||||
args: [{ name: 'skillName', description: 'The name of the skill to load' }]
|
||||
};
|
||||
|
||||
export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface ResolvedSkillsVariable extends ResolvedAIVariable {
|
||||
skills: SkillSummary[];
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class SkillsVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
|
||||
@inject(SkillService)
|
||||
protected readonly skillService: SkillService;
|
||||
|
||||
@inject(ILogger)
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(SKILLS_VARIABLE, this);
|
||||
service.registerResolver(SKILL_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise<number> {
|
||||
if (request.variable.name === SKILLS_VARIABLE.name || request.variable.name === SKILL_VARIABLE.name) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise<ResolvedSkillsVariable | ResolvedAIVariable | undefined> {
|
||||
// Handle singular skill variable with argument
|
||||
if (request.variable.name === SKILL_VARIABLE.name) {
|
||||
return this.resolveSingleSkill(request);
|
||||
}
|
||||
|
||||
// Handle plural skills variable
|
||||
if (request.variable.name === SKILLS_VARIABLE.name) {
|
||||
const skills = this.skillService.getSkills();
|
||||
this.logger.debug(`SkillsVariableContribution: Resolving skills variable, found ${skills.length} skills`);
|
||||
|
||||
const skillSummaries: SkillSummary[] = skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
location: skill.location
|
||||
}));
|
||||
|
||||
const xmlValue = this.generateSkillsXML(skillSummaries);
|
||||
this.logger.debug(`SkillsVariableContribution: Generated XML:\n${xmlValue}`);
|
||||
|
||||
return { variable: SKILLS_VARIABLE, skills: skillSummaries, value: xmlValue };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async resolveSingleSkill(request: AIVariableResolutionRequest): Promise<ResolvedAIVariable | undefined> {
|
||||
const skillName = request.arg;
|
||||
if (!skillName) {
|
||||
this.logger.warn('skill variable requires a skill name argument');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const skill = this.skillService.getSkill(skillName);
|
||||
if (!skill) {
|
||||
this.logger.warn(`Skill not found: ${skillName}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const skillFileUri = URI.fromFilePath(skill.location);
|
||||
const fileContent = await this.fileService.read(skillFileUri);
|
||||
const parsed = parseSkillFile(fileContent.value);
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: parsed.content
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load skill content for '${skillName}': ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates XML representation of skills.
|
||||
* XML format follows the Agent Skills spec for structured skill representation.
|
||||
*/
|
||||
protected generateSkillsXML(skills: SkillSummary[]): string {
|
||||
if (skills.length === 0) {
|
||||
return '<available_skills>\n</available_skills>';
|
||||
}
|
||||
|
||||
const skillElements = skills.map(skill =>
|
||||
'<skill>\n' +
|
||||
`<name>${this.escapeXml(skill.name)}</name>\n` +
|
||||
`<description>${this.escapeXml(skill.description)}</description>\n` +
|
||||
`<location>${this.escapeXml(skill.location)}</location>\n` +
|
||||
'</skill>'
|
||||
).join('\n');
|
||||
|
||||
return `<available_skills>\n${skillElements}\n</available_skills>`;
|
||||
}
|
||||
|
||||
protected escapeXml(text: string): string {
|
||||
const QUOT = '"';
|
||||
const APOS = ''';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, QUOT)
|
||||
.replace(/'/g, APOS);
|
||||
}
|
||||
}
|
||||
153
packages/ai-core/src/browser/theia-variable-contribution.ts
Normal file
153
packages/ai-core/src/browser/theia-variable-contribution.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { VariableRegistry, VariableResolverService } from '@theia/variable-resolver/lib/browser';
|
||||
import { AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext, ResolvedAIVariable } from '../common';
|
||||
|
||||
/**
|
||||
* Mapping configuration for a Theia variable to one or more AI variables
|
||||
*/
|
||||
interface VariableMapping {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrates the Theia VariableRegistry with the Theia AI VariableService
|
||||
*/
|
||||
@injectable()
|
||||
export class TheiaVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
|
||||
private static readonly THEIA_PREFIX = 'theia-';
|
||||
|
||||
@inject(VariableResolverService)
|
||||
protected readonly variableResolverService: VariableResolverService;
|
||||
|
||||
@inject(VariableRegistry)
|
||||
protected readonly variableRegistry: VariableRegistry;
|
||||
|
||||
@inject(FrontendApplicationStateService)
|
||||
protected readonly stateService: FrontendApplicationStateService;
|
||||
|
||||
// Map original variable name to one or more mappings with new name and description.
|
||||
// Only variables present in this map are registered.
|
||||
protected variableRenameMap: Map<string, VariableMapping[]> = new Map([
|
||||
['file', [
|
||||
{
|
||||
name: 'currentAbsoluteFilePath',
|
||||
description: nls.localize('theia/ai/core/variable-contribution/currentAbsoluteFilePath', 'The absolute path of the \
|
||||
currently opened file. Please note that most agents will expect a relative file path (relative to the current workspace).')
|
||||
}
|
||||
]],
|
||||
['selectedText', [
|
||||
{
|
||||
description: nls.localize('theia/ai/core/variable-contribution/currentSelectedText', 'The plain text that is currently selected in the \
|
||||
opened file. This excludes the information where the content is coming from. Please note that most agents will work better with a relative file path \
|
||||
(relative to the current workspace).')
|
||||
}
|
||||
]],
|
||||
['currentText', [
|
||||
{
|
||||
name: 'currentFileContent',
|
||||
description: nls.localize('theia/ai/core/variable-contribution/currentFileContent', 'The plain content of the \
|
||||
currently opened file. This excludes the information where the content is coming from. Please note that most agents will work better with a relative file path \
|
||||
(relative to the current workspace).')
|
||||
}
|
||||
]],
|
||||
['relativeFile', [
|
||||
{
|
||||
name: 'currentRelativeFilePath',
|
||||
description: nls.localize('theia/ai/core/variable-contribution/currentRelativeFilePath', 'The relative path of the \
|
||||
currently opened file.')
|
||||
},
|
||||
{
|
||||
name: '_f',
|
||||
description: nls.localize('theia/ai/core/variable-contribution/dotRelativePath', 'Short reference to the relative path of the \
|
||||
currently opened file (\'currentRelativeFilePath\').')
|
||||
}
|
||||
]],
|
||||
['relativeFileDirname', [
|
||||
{
|
||||
name: 'currentRelativeDirPath',
|
||||
description: nls.localize('theia/ai/core/variable-contribution/currentRelativeDirPath', 'The relative path of the directory \
|
||||
containing the currently opened file.')
|
||||
}
|
||||
]],
|
||||
['lineNumber', [{}]],
|
||||
['workspaceFolder', [{}]]
|
||||
]);
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
this.stateService.reachedState('initialized_layout').then(() => {
|
||||
// some variable contributions in Theia are done as part of the onStart, same as our AI variable contributions
|
||||
// we therefore wait for all of them to be registered before we register we map them to our own
|
||||
this.variableRegistry.getVariables().forEach(variable => {
|
||||
if (!this.variableRenameMap.has(variable.name)) {
|
||||
return; // Do not register variables not part of the map
|
||||
}
|
||||
|
||||
const mappings = this.variableRenameMap.get(variable.name)!;
|
||||
|
||||
// Register each mapping for this variable
|
||||
mappings.forEach((mapping, index) => {
|
||||
const newName = (mapping.name && mapping.name.trim() !== '') ? mapping.name : variable.name;
|
||||
const newDescription = (mapping.description && mapping.description.trim() !== '') ? mapping.description
|
||||
: (variable.description && variable.description.trim() !== '' ? variable.description
|
||||
: nls.localize('theia/ai/core/variable-contribution/builtInVariable', 'Theia Built-in Variable'));
|
||||
|
||||
// For multiple mappings of the same variable, add a suffix to the ID to make it unique
|
||||
const idSuffix = mappings.length > 1 ? `-${index}` : '';
|
||||
const id = `${TheiaVariableContribution.THEIA_PREFIX}${variable.name}${idSuffix}`;
|
||||
|
||||
service.registerResolver({
|
||||
id,
|
||||
name: newName,
|
||||
description: newDescription
|
||||
}, this);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected toTheiaVariable(request: AIVariableResolutionRequest): string {
|
||||
// Extract the base variable name by removing the THEIA_PREFIX and any potential index suffix
|
||||
let variableId = request.variable.id;
|
||||
if (variableId.startsWith(TheiaVariableContribution.THEIA_PREFIX)) {
|
||||
variableId = variableId.slice(TheiaVariableContribution.THEIA_PREFIX.length);
|
||||
// Remove any potential index suffix (e.g., -0, -1)
|
||||
variableId = variableId.replace(/-\d+$/, '');
|
||||
}
|
||||
|
||||
return `\${${variableId}${request.arg ? ':' + request.arg : ''}}`;
|
||||
}
|
||||
|
||||
async canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<number> {
|
||||
if (!request.variable.id.startsWith(TheiaVariableContribution.THEIA_PREFIX)) {
|
||||
return 0;
|
||||
}
|
||||
// some variables are not resolvable without providing a specific context
|
||||
// this may be expensive but was not a problem for Theia's built-in variables
|
||||
const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context);
|
||||
return !resolved ? 0 : 1;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context);
|
||||
return resolved ? { value: resolved, variable: request.variable } : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Emitter } from '@theia/core';
|
||||
import { ModelTokenUsageData, TokenUsageFrontendService } from './token-usage-frontend-service';
|
||||
import { TokenUsage, TokenUsageService } from '../common/token-usage-service';
|
||||
import { TokenUsageServiceClient } from '../common/protocol';
|
||||
|
||||
@injectable()
|
||||
export class TokenUsageServiceClientImpl implements TokenUsageServiceClient {
|
||||
private readonly _onTokenUsageUpdated = new Emitter<TokenUsage>();
|
||||
readonly onTokenUsageUpdated = this._onTokenUsageUpdated.event;
|
||||
|
||||
notifyTokenUsage(usage: TokenUsage): void {
|
||||
this._onTokenUsageUpdated.fire(usage);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TokenUsageFrontendServiceImpl implements TokenUsageFrontendService {
|
||||
|
||||
@inject(TokenUsageServiceClient)
|
||||
protected readonly tokenUsageServiceClient: TokenUsageServiceClient;
|
||||
|
||||
@inject(TokenUsageService)
|
||||
protected readonly tokenUsageService: TokenUsageService;
|
||||
|
||||
private readonly _onTokenUsageUpdated = new Emitter<ModelTokenUsageData[]>();
|
||||
readonly onTokenUsageUpdated = this._onTokenUsageUpdated.event;
|
||||
|
||||
private cachedUsageData: ModelTokenUsageData[] = [];
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.tokenUsageServiceClient.onTokenUsageUpdated(() => {
|
||||
this.getTokenUsageData().then(data => {
|
||||
this._onTokenUsageUpdated.fire(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current token usage data for all models
|
||||
*/
|
||||
async getTokenUsageData(): Promise<ModelTokenUsageData[]> {
|
||||
try {
|
||||
const usages = await this.tokenUsageService.getTokenUsages();
|
||||
this.cachedUsageData = this.aggregateTokenUsages(usages);
|
||||
return this.cachedUsageData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get token usage data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates token usages by model
|
||||
*/
|
||||
private aggregateTokenUsages(usages: TokenUsage[]): ModelTokenUsageData[] {
|
||||
// Group by model
|
||||
const modelMap = new Map<string, {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
readCachedInputTokens: number;
|
||||
lastUsed?: Date;
|
||||
}>();
|
||||
|
||||
// Process each usage record
|
||||
for (const usage of usages) {
|
||||
const existing = modelMap.get(usage.model);
|
||||
|
||||
if (existing) {
|
||||
existing.inputTokens += usage.inputTokens;
|
||||
existing.outputTokens += usage.outputTokens;
|
||||
|
||||
// Add cached tokens if they exist
|
||||
if (usage.cachedInputTokens !== undefined) {
|
||||
existing.cachedInputTokens += usage.cachedInputTokens;
|
||||
}
|
||||
|
||||
// Add read cached tokens if they exist
|
||||
if (usage.readCachedInputTokens !== undefined) {
|
||||
existing.readCachedInputTokens += usage.readCachedInputTokens;
|
||||
}
|
||||
|
||||
// Update last used if this usage is more recent
|
||||
if (!existing.lastUsed || (usage.timestamp && usage.timestamp > existing.lastUsed)) {
|
||||
existing.lastUsed = usage.timestamp;
|
||||
}
|
||||
} else {
|
||||
modelMap.set(usage.model, {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedInputTokens: usage.cachedInputTokens || 0,
|
||||
readCachedInputTokens: usage.readCachedInputTokens || 0,
|
||||
lastUsed: usage.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to array of model usage data
|
||||
const result: ModelTokenUsageData[] = [];
|
||||
|
||||
for (const [modelId, data] of modelMap.entries()) {
|
||||
const modelData: ModelTokenUsageData = {
|
||||
modelId,
|
||||
inputTokens: data.inputTokens,
|
||||
outputTokens: data.outputTokens,
|
||||
lastUsed: data.lastUsed
|
||||
};
|
||||
|
||||
// Only include cache-related fields if they have non-zero values
|
||||
if (data.cachedInputTokens > 0) {
|
||||
modelData.cachedInputTokens = data.cachedInputTokens;
|
||||
}
|
||||
|
||||
if (data.readCachedInputTokens > 0) {
|
||||
modelData.readCachedInputTokens = data.readCachedInputTokens;
|
||||
}
|
||||
|
||||
result.push(modelData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
51
packages/ai-core/src/browser/token-usage-frontend-service.ts
Normal file
51
packages/ai-core/src/browser/token-usage-frontend-service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Event } from '@theia/core';
|
||||
|
||||
/**
|
||||
* Data structure for token usage data specific to a model.
|
||||
*/
|
||||
export interface ModelTokenUsageData {
|
||||
/** The model identifier */
|
||||
modelId: string;
|
||||
/** Number of input tokens used */
|
||||
inputTokens: number;
|
||||
/** Number of output tokens used */
|
||||
outputTokens: number;
|
||||
/** Number of input tokens written to cache */
|
||||
cachedInputTokens?: number;
|
||||
/** Number of input tokens read from cache */
|
||||
readCachedInputTokens?: number;
|
||||
/** Date when the model was last used */
|
||||
lastUsed?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing token usage data on the frontend.
|
||||
*/
|
||||
export const TokenUsageFrontendService = Symbol('TokenUsageFrontendService');
|
||||
export interface TokenUsageFrontendService {
|
||||
/**
|
||||
* Event emitted when token usage data is updated
|
||||
*/
|
||||
readonly onTokenUsageUpdated: Event<ModelTokenUsageData[]>;
|
||||
|
||||
/**
|
||||
* Gets the current token usage data for all models
|
||||
*/
|
||||
getTokenUsageData(): Promise<ModelTokenUsageData[]>;
|
||||
}
|
||||
195
packages/ai-core/src/browser/window-blink-service.ts
Normal file
195
packages/ai-core/src/browser/window-blink-service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { environment, nls } from '@theia/core';
|
||||
|
||||
/**
|
||||
* Result of a window blink attempt
|
||||
*/
|
||||
export interface WindowBlinkResult {
|
||||
/** Whether the window blink was successful */
|
||||
success: boolean;
|
||||
/** Error message if the blink failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for blinking/flashing the application window to get user attention.
|
||||
*/
|
||||
@injectable()
|
||||
export class WindowBlinkService {
|
||||
|
||||
private isElectron: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isElectron = environment.electron.is();
|
||||
}
|
||||
|
||||
/**
|
||||
* Blink/flash the window to get user attention.
|
||||
* The implementation varies depending on the platform and environment.
|
||||
*
|
||||
* @param agentName Optional name of the agent to include in the blink notification
|
||||
*/
|
||||
async blinkWindow(agentName?: string): Promise<WindowBlinkResult> {
|
||||
try {
|
||||
if (this.isElectron) {
|
||||
await this.blinkElectronWindow(agentName);
|
||||
} else {
|
||||
await this.blinkBrowserWindow(agentName);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.warn('Failed to blink window:', error);
|
||||
try {
|
||||
if (document.hidden) {
|
||||
this.focusWindow();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (fallbackError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to blink window'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async blinkElectronWindow(agentName?: string): Promise<void> {
|
||||
await this.blinkDocumentTitle(agentName);
|
||||
|
||||
if (document.hidden) {
|
||||
try {
|
||||
const theiaCoreAPI = (window as unknown as { electronTheiaCore?: { focusWindow?: () => void } }).electronTheiaCore;
|
||||
if (theiaCoreAPI?.focusWindow) {
|
||||
theiaCoreAPI.focusWindow();
|
||||
} else {
|
||||
window.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not focus hidden window:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async blinkBrowserWindow(agentName?: string): Promise<void> {
|
||||
await this.blinkDocumentTitle(agentName);
|
||||
this.blinkWithVisibilityAPI();
|
||||
if (document.hidden) {
|
||||
this.focusWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private async blinkDocumentTitle(agentName?: string): Promise<void> {
|
||||
const originalTitle = document.title;
|
||||
const alertTitle = '🔔 ' + (agentName
|
||||
? nls.localize('theia/ai/core/blinkTitle/namedAgentCompleted', 'Theia - Agent "{0}" Completed', agentName)
|
||||
: nls.localize('theia/ai/core/blinkTitle/agentCompleted', 'Theia - Agent Completed'));
|
||||
|
||||
let blinkCount = 0;
|
||||
const maxBlinks = 6;
|
||||
|
||||
const blinkInterval = setInterval(() => {
|
||||
if (blinkCount >= maxBlinks) {
|
||||
clearInterval(blinkInterval);
|
||||
document.title = originalTitle;
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = blinkCount % 2 === 0 ? alertTitle : originalTitle;
|
||||
blinkCount++;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private blinkWithVisibilityAPI(): void {
|
||||
// This method provides visual attention-getting behavior without creating notifications
|
||||
// as notifications are handled by the OSNotificationService to avoid duplicates
|
||||
if (!this.isElectron && typeof document.hidden !== 'undefined') {
|
||||
// Focus the window if it's hidden to get user attention
|
||||
if (document.hidden) {
|
||||
this.focusWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private focusWindow(): void {
|
||||
try {
|
||||
window.focus();
|
||||
|
||||
// Try to scroll to top to create some visual movement
|
||||
if (document.body.scrollTop > 0 || document.documentElement.scrollTop > 0) {
|
||||
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
window.scrollTo(0, 0);
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, currentScroll);
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not focus window:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if window blinking is supported in the current environment.
|
||||
*/
|
||||
isBlinkSupported(): boolean {
|
||||
if (this.isElectron) {
|
||||
const theiaCoreAPI = (window as unknown as { electronTheiaCore?: { focusWindow?: () => void } }).electronTheiaCore;
|
||||
return !!(theiaCoreAPI?.focusWindow);
|
||||
}
|
||||
|
||||
// In browser, we can always provide some form of attention-getting behavior
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the blinking capabilities.
|
||||
*/
|
||||
getBlinkCapabilities(): {
|
||||
supported: boolean;
|
||||
method: 'electron' | 'browser' | 'none';
|
||||
features: string[];
|
||||
} {
|
||||
const features: string[] = [];
|
||||
let method: 'electron' | 'browser' | 'none' = 'none';
|
||||
|
||||
if (this.isElectron) {
|
||||
method = 'electron';
|
||||
const theiaCoreAPI = (window as unknown as { electronTheiaCore?: { focusWindow?: () => void } }).electronTheiaCore;
|
||||
|
||||
if (theiaCoreAPI?.focusWindow) {
|
||||
features.push('electronTheiaCore.focusWindow');
|
||||
features.push('document.title blinking');
|
||||
features.push('window.focus');
|
||||
}
|
||||
} else {
|
||||
method = 'browser';
|
||||
features.push('document.title');
|
||||
features.push('window.focus');
|
||||
|
||||
if (typeof document.hidden !== 'undefined') {
|
||||
features.push('Page Visibility API');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
supported: features.length > 0,
|
||||
method,
|
||||
features
|
||||
};
|
||||
}
|
||||
}
|
||||
88
packages/ai-core/src/common/agent-preferences.ts
Normal file
88
packages/ai-core/src/common/agent-preferences.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { nls, PreferenceSchema } from '@theia/core';
|
||||
import {
|
||||
NOTIFICATION_TYPES
|
||||
} from './notification-types';
|
||||
|
||||
export const AGENT_SETTINGS_PREF = 'ai-features.agentSettings';
|
||||
|
||||
export const AgentSettingsPreferenceSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
[AGENT_SETTINGS_PREF]: {
|
||||
type: 'object',
|
||||
title: nls.localize('theia/ai/agents/title', 'Agent Settings'),
|
||||
hidden: true,
|
||||
markdownDescription: nls.localize('theia/ai/agents/mdDescription', 'Configure agent settings such as enabling or disabling specific agents, configuring prompts and \
|
||||
selecting LLMs.'),
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enable: {
|
||||
type: 'boolean',
|
||||
title: nls.localize('theia/ai/agents/enable/title', 'Enable Agent'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/enable/mdDescription', 'Specifies whether the agent should be enabled (true) or disabled (false).'),
|
||||
default: true
|
||||
},
|
||||
languageModelRequirements: {
|
||||
type: 'array',
|
||||
title: nls.localize('theia/ai/agents/languageModelRequirements/title', 'Language Model Requirements'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/languageModelRequirements/mdDescription', 'Specifies the used language models for this agent.'),
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
purpose: {
|
||||
type: 'string',
|
||||
title: nls.localize('theia/ai/agents/languageModelRequirements/purpose/title', 'Purpose'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/languageModelRequirements/purpose/mdDescription',
|
||||
'The purpose for which this language model is used.')
|
||||
},
|
||||
identifier: {
|
||||
type: 'string',
|
||||
title: nls.localizeByDefault('Identifier'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/languageModelRequirements/identifier/mdDescription',
|
||||
'The identifier of the language model to be used.')
|
||||
}
|
||||
},
|
||||
required: ['purpose', 'identifier']
|
||||
}
|
||||
},
|
||||
selectedVariants: {
|
||||
type: 'object',
|
||||
title: nls.localize('theia/ai/agents/selectedVariants/title', 'Selected Variants'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/selectedVariants/mdDescription', 'Specifies the currently selected prompt variants for this agent.'),
|
||||
additionalProperties: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
completionNotification: {
|
||||
type: 'string',
|
||||
enum: [...NOTIFICATION_TYPES],
|
||||
title: nls.localize('theia/ai/agents/completionNotification/title', 'Completion Notification'),
|
||||
markdownDescription: nls.localize('theia/ai/agents/completionNotification/mdDescription',
|
||||
'Notification behavior when this agent completes a task. If not set, the global default notification setting will be used.\n\
|
||||
- `os-notification`: Show OS/system notifications\n\
|
||||
- `message`: Show notifications in the status bar/message area\n\
|
||||
- `blink`: Blink or highlight the UI\n\
|
||||
- `off`: Disable notifications for this agent')
|
||||
}
|
||||
},
|
||||
required: ['languageModelRequirements']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
149
packages/ai-core/src/common/agent-service.ts
Normal file
149
packages/ai-core/src/common/agent-service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Emitter, Event } from '@theia/core';
|
||||
import { Agent } from './agent';
|
||||
import { AISettingsService } from './settings-service';
|
||||
import { PromptService } from './prompt-service';
|
||||
|
||||
export const AgentService = Symbol('AgentService');
|
||||
|
||||
/**
|
||||
* Service to access the list of known Agents.
|
||||
*/
|
||||
export interface AgentService {
|
||||
/**
|
||||
* Retrieves a list of all available agents, i.e. agents which are not disabled
|
||||
*/
|
||||
getAgents(): Agent[];
|
||||
/**
|
||||
* Retrieves a list of all agents, including disabled ones.
|
||||
*/
|
||||
getAllAgents(): Agent[];
|
||||
/**
|
||||
* Enable the agent with the specified id.
|
||||
* @param agentId the agent id.
|
||||
*/
|
||||
enableAgent(agentId: string): Promise<void>;
|
||||
/**
|
||||
* disable the agent with the specified id.
|
||||
* @param agentId the agent id.
|
||||
*/
|
||||
disableAgent(agentId: string): Promise<void>;
|
||||
/**
|
||||
* query whether this agent is currently enabled or disabled.
|
||||
* @param agentId the agent id.
|
||||
* @return true if the agent is enabled, false otherwise.
|
||||
*/
|
||||
isEnabled(agentId: string): boolean;
|
||||
|
||||
/**
|
||||
* Allows to register an agent programmatically.
|
||||
* @param agent the agent to register
|
||||
*/
|
||||
registerAgent(agent: Agent): void;
|
||||
|
||||
/**
|
||||
* Allows to unregister an agent programmatically.
|
||||
* @param agentId the agent id to unregister
|
||||
*/
|
||||
unregisterAgent(agentId: string): void;
|
||||
|
||||
/**
|
||||
* Emitted when the list of agents changes.
|
||||
* This can be used to update the UI when agents are added or removed.
|
||||
*/
|
||||
onDidChangeAgents: Event<void>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class AgentServiceImpl implements AgentService {
|
||||
|
||||
@inject(AISettingsService) @optional()
|
||||
protected readonly aiSettingsService: AISettingsService | undefined;
|
||||
|
||||
@inject(PromptService)
|
||||
protected readonly promptService: PromptService;
|
||||
|
||||
protected disabledAgents = new Set<string>();
|
||||
|
||||
protected _agents: Agent[] = [];
|
||||
|
||||
private readonly onDidChangeAgentsEmitter = new Emitter<void>();
|
||||
readonly onDidChangeAgents = this.onDidChangeAgentsEmitter.event;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.aiSettingsService?.getSettings().then(settings => {
|
||||
Object.entries(settings).forEach(([agentId, agentSettings]) => {
|
||||
if (agentSettings.enable === false) {
|
||||
this.disabledAgents.add(agentId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerAgent(agent: Agent): void {
|
||||
this._agents.push(agent);
|
||||
agent.prompts.forEach(
|
||||
prompt => {
|
||||
this.promptService.addBuiltInPromptFragment(prompt.defaultVariant, prompt.id, true);
|
||||
prompt.variants?.forEach(variant => {
|
||||
this.promptService.addBuiltInPromptFragment(variant, prompt.id);
|
||||
});
|
||||
}
|
||||
);
|
||||
this.onDidChangeAgentsEmitter.fire();
|
||||
}
|
||||
|
||||
unregisterAgent(agentId: string): void {
|
||||
const agent = this._agents.find(a => a.id === agentId);
|
||||
this._agents = this._agents.filter(a => a.id !== agentId);
|
||||
this.onDidChangeAgentsEmitter.fire();
|
||||
agent?.prompts.forEach(
|
||||
prompt => {
|
||||
this.promptService.removePromptFragment(prompt.defaultVariant.id);
|
||||
prompt.variants?.forEach(variant => {
|
||||
this.promptService.removePromptFragment(variant.id);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getAgents(): Agent[] {
|
||||
return this._agents.filter(agent => this.isEnabled(agent.id));
|
||||
}
|
||||
|
||||
getAllAgents(): Agent[] {
|
||||
return this._agents;
|
||||
}
|
||||
|
||||
async enableAgent(agentId: string): Promise<void> {
|
||||
this.disabledAgents.delete(agentId);
|
||||
await this.aiSettingsService?.updateAgentSettings(agentId, { enable: true });
|
||||
this.onDidChangeAgentsEmitter.fire();
|
||||
}
|
||||
|
||||
async disableAgent(agentId: string): Promise<void> {
|
||||
this.disabledAgents.add(agentId);
|
||||
await this.aiSettingsService?.updateAgentSettings(agentId, { enable: false });
|
||||
this.onDidChangeAgentsEmitter.fire();
|
||||
}
|
||||
|
||||
isEnabled(agentId: string): boolean {
|
||||
return !this.disabledAgents.has(agentId);
|
||||
}
|
||||
}
|
||||
98
packages/ai-core/src/common/agent.ts
Normal file
98
packages/ai-core/src/common/agent.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { LanguageModelRequirement } from './language-model';
|
||||
import { BasePromptFragment } from './prompt-service';
|
||||
|
||||
export interface AgentSpecificVariables {
|
||||
name: string;
|
||||
description: string;
|
||||
usedInPrompt: boolean;
|
||||
}
|
||||
|
||||
export interface PromptVariantSet {
|
||||
id: string;
|
||||
defaultVariant: BasePromptFragment;
|
||||
variants?: BasePromptFragment[];
|
||||
}
|
||||
|
||||
export const Agent = Symbol('Agent');
|
||||
/**
|
||||
* Agents represent the main functionality of the AI system. They are responsible for processing user input, collecting information from the environment,
|
||||
* invoking and processing LLM responses, and providing the final response to the user while recording their actions in the AI history.
|
||||
*
|
||||
* Agents are meant to cover all use cases, from specialized scenarios to general purpose chat bots.
|
||||
*
|
||||
* Agents are encouraged to provide a detailed description of their functionality and their processed inputs.
|
||||
* They can also declare their used prompt templates, which makes them configurable for the user.
|
||||
*/
|
||||
export interface Agent {
|
||||
/**
|
||||
* Used to identify an agent, e.g. when it is requesting language models, etc.
|
||||
*
|
||||
* @note This parameter might be removed in favor of `name`. Therefore, it is recommended to set `id` to the same value as `name` for now.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Human-readable name shown to users to identify the agent. Must be unique.
|
||||
* Use short names without "Agent" or "Chat" (see `tags` for adding further properties).
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/** A markdown description of its functionality and its privacy-relevant requirements, including function call handlers that access some data autonomously. */
|
||||
readonly description: string;
|
||||
|
||||
/**
|
||||
* The list of global variable identifiers that are always available to this agent during execution,
|
||||
* regardless of whether they are referenced in prompts.
|
||||
*
|
||||
* This array is primarily used for documentation purposes in the AI Configuration View
|
||||
* to show which variables are guaranteed to be available to the agent. Referenced variables are NOT automatically handed over by the framework,
|
||||
* this must be explicitly done in the agent implementation.
|
||||
*/
|
||||
readonly variables: string[];
|
||||
|
||||
/** The prompts introduced and used by this agent. */
|
||||
readonly prompts: PromptVariantSet[];
|
||||
|
||||
/** Required language models. This includes the purpose and optional language model selector arguments. See #47. */
|
||||
readonly languageModelRequirements: LanguageModelRequirement[];
|
||||
|
||||
/** A list of tags to filter agents and to display capabilities in the UI */
|
||||
readonly tags?: string[];
|
||||
|
||||
/**
|
||||
* The list of local variable identifiers that can be made available to this agent during execution,
|
||||
* these variables are context specific and do not exist for other agents.
|
||||
*
|
||||
* This array is primarily used for documentation purposes in the AI Configuration View
|
||||
* to show which variables can be made available to the agent.
|
||||
* Referenced variables are NOT automatically handed over by the framework,
|
||||
* this must be explicitly done in the agent implementation or in prompts.
|
||||
*/
|
||||
readonly agentSpecificVariables: AgentSpecificVariables[];
|
||||
|
||||
/**
|
||||
* The list of global function identifiers that are always available to this agent during execution,
|
||||
* regardless of whether they are referenced in prompts.
|
||||
*
|
||||
* This array is primarily used for documentation purposes in the AI Configuration View
|
||||
* to show which functions are guaranteed to be available to the agent. Referenced functions are NOT automatically handed over by the framework,
|
||||
* this must be explicitly done in the agent implementation.
|
||||
*/
|
||||
readonly functions: string[];
|
||||
}
|
||||
64
packages/ai-core/src/common/agents-variable-contribution.ts
Normal file
64
packages/ai-core/src/common/agents-variable-contribution.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from './variable-service';
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { AgentService } from './agent-service';
|
||||
|
||||
export const AGENTS_VARIABLE: AIVariable = {
|
||||
id: 'agents',
|
||||
name: 'agents',
|
||||
description: nls.localize('theia/ai/core/agentsVariable/description', 'Returns the list of agents available in the system')
|
||||
};
|
||||
|
||||
export interface ResolvedAgentsVariable extends ResolvedAIVariable {
|
||||
agents: AgentDescriptor[];
|
||||
}
|
||||
|
||||
export interface AgentDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class AgentsVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
|
||||
@inject(AgentService)
|
||||
protected readonly agentService: AgentService;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(AGENTS_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise<number> {
|
||||
if (request.variable.name === AGENTS_VARIABLE.name) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAgentsVariable | undefined> {
|
||||
if (request.variable.name === AGENTS_VARIABLE.name) {
|
||||
const agents = this.agentService.getAgents().map(agent => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description
|
||||
}));
|
||||
return { variable: AGENTS_VARIABLE, agents, value: JSON.stringify(agents) };
|
||||
}
|
||||
}
|
||||
}
|
||||
261
packages/ai-core/src/common/ai-core-preferences.ts
Normal file
261
packages/ai-core/src/common/ai-core-preferences.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { nls, PreferenceProxyFactory } from '@theia/core';
|
||||
import { PreferenceProxy } from '@theia/core/lib/common';
|
||||
import { interfaces } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
NOTIFICATION_TYPES,
|
||||
NOTIFICATION_TYPE_OFF,
|
||||
NotificationType
|
||||
} from './notification-types';
|
||||
import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
|
||||
|
||||
export const AI_CORE_PREFERENCES_TITLE = '✨ ' + nls.localize('theia/ai/core/prefs/title', 'AI Features [Beta]');
|
||||
export const PREFERENCE_NAME_PROMPT_TEMPLATES = 'ai-features.promptTemplates.promptTemplatesFolder';
|
||||
export const PREFERENCE_NAME_REQUEST_SETTINGS = 'ai-features.modelSettings.requestSettings';
|
||||
export const PREFERENCE_NAME_MAX_RETRIES = 'ai-features.modelSettings.maxRetries';
|
||||
export const PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE = 'ai-features.notifications.default';
|
||||
export const PREFERENCE_NAME_SKILL_DIRECTORIES = 'ai-features.skills.skillDirectories';
|
||||
|
||||
export const LANGUAGE_MODEL_ALIASES_PREFERENCE = 'ai-features.languageModelAliases';
|
||||
|
||||
export const aiCorePreferenceSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
[PREFERENCE_NAME_PROMPT_TEMPLATES]: {
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
description: nls.localize('theia/ai/core/promptTemplates/description',
|
||||
'Folder for storing customized prompt templates. If not customized the user config directory is used. Please consider to use a folder, which is\
|
||||
under version control to manage your variants of prompt templates.'),
|
||||
type: 'string',
|
||||
default: '',
|
||||
typeDetails: {
|
||||
isFilepath: true,
|
||||
selectionProps: {
|
||||
openLabel: nls.localize('theia/ai/core/promptTemplates/openLabel', 'Select Folder'),
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false
|
||||
}
|
||||
},
|
||||
},
|
||||
[PREFERENCE_NAME_REQUEST_SETTINGS]: {
|
||||
title: nls.localize('theia/ai/core/requestSettings/title', 'Custom Request Settings'),
|
||||
markdownDescription: nls.localize('theia/ai/core/requestSettings/mdDescription', 'Allows specifying custom request settings for multiple models.\n\
|
||||
Each setting consists of:\n\
|
||||
- `scope`: Defines when the setting applies:\n\
|
||||
- `modelId` (optional): The model ID to match\n\
|
||||
- `providerId` (optional): The provider ID to match (e.g., huggingface, openai, ollama, llamafile)\n\
|
||||
- `agentId` (optional): The agent ID to match\n\
|
||||
- `requestSettings`: Model-specific settings as key-value pairs\n\
|
||||
- `clientSettings`: Client-side message handling settings:\n\
|
||||
- `keepToolCalls` (boolean): Whether to keep tool calls in the context\n\
|
||||
- `keepThinking` (boolean): Whether to keep thinking messages\n\
|
||||
Settings are matched based on specificity (agent: 100, model: 10, provider: 1 points).\n\
|
||||
Refer to [our documentation](https://theia-ide.org/docs/user_ai/#custom-request-settings) for more information.'),
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scope: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
modelId: {
|
||||
type: 'string',
|
||||
description: nls.localize('theia/ai/core/requestSettings/scope/modelId/description', 'The (optional) model id')
|
||||
},
|
||||
providerId: {
|
||||
type: 'string',
|
||||
description: nls.localize('theia/ai/core/requestSettings/scope/providerId/description', 'The (optional) provider id to apply the settings to.'),
|
||||
},
|
||||
agentId: {
|
||||
type: 'string',
|
||||
description: nls.localize('theia/ai/core/requestSettings/scope/agentId/description', 'The (optional) agent id to apply the settings to.'),
|
||||
},
|
||||
}
|
||||
},
|
||||
requestSettings: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: nls.localize('theia/ai/core/requestSettings/modelSpecificSettings/description', 'Settings for the specific model ID.'),
|
||||
},
|
||||
clientSettings: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: nls.localize('theia/ai/core/requestSettings/clientSettings/description',
|
||||
'Client settings for how to handle messages that are send back to the llm.'),
|
||||
properties: {
|
||||
keepToolCalls: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: nls.localize('theia/ai/core/requestSettings/clientSettings/keepToolCalls/description',
|
||||
'If set to false, all tool request and tool responses will be filtered \
|
||||
before sending the next user request in a multi-turn conversation.')
|
||||
},
|
||||
keepThinking: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: nls.localize('theia/ai/core/requestSettings/clientSettings/keepThinking/description',
|
||||
'If set to false, all thinking output will be filtered before sending the next user request in a multi-turn conversation.')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
default: [],
|
||||
},
|
||||
[PREFERENCE_NAME_MAX_RETRIES]: {
|
||||
title: nls.localize('theia/ai/core/maxRetries/title', 'Maximum Retries'),
|
||||
markdownDescription: nls.localize('theia/ai/core/maxRetries/mdDescription',
|
||||
'The maximum number of retry attempts when a request to an AI provider fails. A value of 0 means no retries.'),
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 3
|
||||
},
|
||||
[PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: {
|
||||
title: nls.localize('theia/ai/core/defaultNotification/title', 'Default Notification Type'),
|
||||
markdownDescription: nls.localize('theia/ai/core/defaultNotification/mdDescription',
|
||||
'The default notification method used when an AI agent completes a task. Individual agents can override this setting.\n\
|
||||
- `os-notification`: Show OS/system notifications\n\
|
||||
- `message`: Show notifications in the status bar/message area\n\
|
||||
- `blink`: Blink or highlight the UI\n\
|
||||
- `off`: Disable all notifications'),
|
||||
type: 'string',
|
||||
enum: [...NOTIFICATION_TYPES],
|
||||
default: NOTIFICATION_TYPE_OFF
|
||||
},
|
||||
[PREFERENCE_NAME_SKILL_DIRECTORIES]: {
|
||||
description: nls.localize('theia/ai/core/skillDirectories/description',
|
||||
'Additional directories containing skill definitions (SKILL.md files). Skills provide reusable instructions that can be referenced by AI agents. ' +
|
||||
'The default skills directory in your product\'s configuration folder is always included.'),
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
default: []
|
||||
},
|
||||
[LANGUAGE_MODEL_ALIASES_PREFERENCE]: {
|
||||
title: nls.localize('theia/ai/core/preference/languageModelAliases/title', 'Language Model Aliases'),
|
||||
markdownDescription: nls.localize('theia/ai/core/preference/languageModelAliases/description', 'Configure models for each language model alias in the \
|
||||
[AI Configuration View]({0}). Alternatiely you can set the settings manually in the settings.json: \n\
|
||||
```\n\
|
||||
"default/code": {\n\
|
||||
"selectedModel": "anthropic/claude-opus-4-20250514"\n\
|
||||
}\n\```',
|
||||
'command:aiConfiguration:open'
|
||||
),
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selectedModel: {
|
||||
type: 'string',
|
||||
description: nls.localize('theia/ai/core/preference/languageModelAliases/selectedModel', 'The user-selected model for this alias.')
|
||||
}
|
||||
},
|
||||
required: ['selectedModel'],
|
||||
additionalProperties: false
|
||||
},
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface AICoreConfiguration {
|
||||
[PREFERENCE_NAME_PROMPT_TEMPLATES]: string | undefined;
|
||||
[PREFERENCE_NAME_REQUEST_SETTINGS]: Array<RequestSetting> | undefined;
|
||||
[PREFERENCE_NAME_MAX_RETRIES]: number | undefined;
|
||||
[PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: NotificationType | undefined;
|
||||
[PREFERENCE_NAME_SKILL_DIRECTORIES]: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface RequestSetting {
|
||||
scope?: Scope;
|
||||
clientSettings?: { keepToolCalls: boolean; keepThinking: boolean };
|
||||
requestSettings?: { [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
modelId?: string;
|
||||
providerId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export const AICorePreferences = Symbol('AICorePreferences');
|
||||
export type AICorePreferences = PreferenceProxy<AICoreConfiguration>;
|
||||
|
||||
export function bindAICorePreferences(bind: interfaces.Bind): void {
|
||||
bind(AICorePreferences).toDynamicValue(ctx => {
|
||||
const factory = ctx.container.get<PreferenceProxyFactory>(PreferenceProxyFactory);
|
||||
return factory(aiCorePreferenceSchema);
|
||||
}).inSingletonScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the specificity score of a RequestSetting for a given scope.
|
||||
* The score is calculated based on matching criteria:
|
||||
* - Agent match: 100 points
|
||||
* - Model match: 10 points
|
||||
* - Provider match: 1 point
|
||||
*
|
||||
* @param setting RequestSetting object to check against
|
||||
* @param scope Optional scope object containing modelId, providerId, and agentId
|
||||
* @returns Specificity score (-1 for non-match, or sum of matching criteria points)
|
||||
*/
|
||||
export const getRequestSettingSpecificity = (setting: RequestSetting, scope?: Scope): number => {
|
||||
// If no scope is defined in the setting, return default specificity
|
||||
if (!setting.scope) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If no matching criteria are defined in the scope, return default specificity
|
||||
if (!setting.scope.modelId && !setting.scope.providerId && !setting.scope.agentId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check for explicit non-matches (return -1)
|
||||
if (scope?.modelId && setting.scope.modelId && setting.scope.modelId !== scope.modelId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (scope?.providerId && setting.scope.providerId && setting.scope.providerId !== scope.providerId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (scope?.agentId && setting.scope.agentId && setting.scope.agentId !== scope.agentId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let specificity = 0;
|
||||
|
||||
// Check provider match (1 point)
|
||||
if (scope?.providerId && setting.scope.providerId === scope.providerId) {
|
||||
specificity += 1;
|
||||
}
|
||||
|
||||
// Check model match (10 points)
|
||||
if (scope?.modelId && setting.scope.modelId === scope.modelId) {
|
||||
specificity += 10;
|
||||
}
|
||||
|
||||
// Check agent match (100 points)
|
||||
if (scope?.agentId && setting.scope.agentId === scope.agentId) {
|
||||
specificity += 100;
|
||||
}
|
||||
|
||||
return specificity;
|
||||
};
|
||||
86
packages/ai-core/src/common/ai-variable-resource.ts
Normal file
86
packages/ai-core/src/common/ai-variable-resource.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as deepEqual from 'fast-deep-equal';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Resource, URI, generateUuid } from '@theia/core';
|
||||
import { AIVariableContext, AIVariableResolutionRequest } from './variable-service';
|
||||
import stableJsonStringify = require('fast-json-stable-stringify');
|
||||
import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from './configurable-in-memory-resources';
|
||||
|
||||
export const AI_VARIABLE_RESOURCE_SCHEME = 'ai-variable';
|
||||
export const NO_CONTEXT_AUTHORITY = 'context-free';
|
||||
|
||||
@injectable()
|
||||
export class AIVariableResourceResolver {
|
||||
@inject(ConfigurableInMemoryResources) protected readonly inMemoryResources: ConfigurableInMemoryResources;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.inMemoryResources.onWillDispose(resource => this.cache.delete(resource.uri.toString()));
|
||||
}
|
||||
|
||||
protected readonly cache = new Map<string, [Resource, AIVariableContext]>();
|
||||
|
||||
getOrCreate(request: AIVariableResolutionRequest, context: AIVariableContext, value: string): ConfigurableMutableReferenceResource {
|
||||
const uri = this.toUri(request, context);
|
||||
try {
|
||||
const existing = this.inMemoryResources.resolve(uri);
|
||||
existing.update({ contents: value });
|
||||
return existing;
|
||||
} catch { /* No-op */ }
|
||||
const fresh = this.inMemoryResources.add(uri, { contents: value, readOnly: true, initiallyDirty: false });
|
||||
const key = uri.toString();
|
||||
this.cache.set(key, [fresh, context]);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
protected toUri(request: AIVariableResolutionRequest, context: AIVariableContext): URI {
|
||||
return URI.fromComponents({
|
||||
scheme: AI_VARIABLE_RESOURCE_SCHEME,
|
||||
query: stableJsonStringify({ arg: request.arg, name: request.variable.name }),
|
||||
path: '/',
|
||||
authority: this.toAuthority(context),
|
||||
fragment: ''
|
||||
});
|
||||
}
|
||||
|
||||
protected toAuthority(context: AIVariableContext): string {
|
||||
try {
|
||||
if (deepEqual(context, {})) { return NO_CONTEXT_AUTHORITY; }
|
||||
for (const [resource, cachedContext] of this.cache.values()) {
|
||||
if (deepEqual(context, cachedContext)) {
|
||||
return resource.uri.authority;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Mostly that deep equal could overflow the stack, but it should run into === or inequality before that.
|
||||
console.warn('Problem evaluating context in AIVariableResourceResolver', err);
|
||||
}
|
||||
return generateUuid();
|
||||
}
|
||||
|
||||
fromUri(uri: URI): { variableName: string, arg: string | undefined } | undefined {
|
||||
if (uri.scheme !== AI_VARIABLE_RESOURCE_SCHEME) { return undefined; }
|
||||
try {
|
||||
const { name: variableName, arg } = JSON.parse(uri.query);
|
||||
return variableName ? {
|
||||
variableName,
|
||||
arg,
|
||||
} : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
}
|
||||
164
packages/ai-core/src/common/configurable-in-memory-resources.ts
Normal file
164
packages/ai-core/src/common/configurable-in-memory-resources.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclispeSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { SyncReferenceCollection, Reference, ResourceResolver, Resource, Event, Emitter, URI } from '@theia/core';
|
||||
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
||||
|
||||
@injectable()
|
||||
/** For creating highly configurable in-memory resources */
|
||||
export class ConfigurableInMemoryResources implements ResourceResolver {
|
||||
|
||||
protected readonly resources = new SyncReferenceCollection<string, ConfigurableMutableResource>(uri => new ConfigurableMutableResource(new URI(uri)));
|
||||
|
||||
get onWillDispose(): Event<ConfigurableMutableResource> {
|
||||
return this.resources.onWillDispose;
|
||||
}
|
||||
|
||||
add(uri: URI, options: ResourceInitializationOptions): ConfigurableMutableReferenceResource {
|
||||
const resourceUri = uri.toString();
|
||||
if (this.resources.has(resourceUri)) {
|
||||
throw new Error(`Cannot add already existing in-memory resource '${resourceUri}'`);
|
||||
}
|
||||
const resource = this.acquire(resourceUri);
|
||||
resource.update(options);
|
||||
return resource;
|
||||
}
|
||||
|
||||
update(uri: URI, options: ResourceInitializationOptions): Resource {
|
||||
const resourceUri = uri.toString();
|
||||
const resource = this.resources.get(resourceUri);
|
||||
if (!resource) {
|
||||
throw new Error(`Cannot update non-existent in-memory resource '${resourceUri}'`);
|
||||
}
|
||||
resource.update(options);
|
||||
return resource;
|
||||
}
|
||||
|
||||
resolve(uri: URI): ConfigurableMutableReferenceResource {
|
||||
const uriString = uri.toString();
|
||||
if (!this.resources.has(uriString)) {
|
||||
throw new Error(`In memory '${uriString}' resource does not exist.`);
|
||||
}
|
||||
return this.acquire(uriString);
|
||||
}
|
||||
|
||||
protected acquire(uri: string): ConfigurableMutableReferenceResource {
|
||||
const reference = this.resources.acquire(uri);
|
||||
return new ConfigurableMutableReferenceResource(reference);
|
||||
}
|
||||
}
|
||||
|
||||
export type ResourceInitializationOptions = Pick<Resource, 'autosaveable' | 'initiallyDirty' | 'readOnly'>
|
||||
& { contents?: string | Promise<string>, onSave?: Resource['saveContents'] };
|
||||
|
||||
export class ConfigurableMutableResource implements Resource {
|
||||
protected readonly onDidChangeContentsEmitter = new Emitter<void>();
|
||||
readonly onDidChangeContents = this.onDidChangeContentsEmitter.event;
|
||||
protected fireDidChangeContents(): void {
|
||||
this.onDidChangeContentsEmitter.fire();
|
||||
}
|
||||
|
||||
protected readonly onDidChangeReadonlyEmitter = new Emitter<boolean | MarkdownString>();
|
||||
readonly onDidChangeReadOnly = this.onDidChangeReadonlyEmitter.event;
|
||||
|
||||
constructor(readonly uri: URI, protected options?: ResourceInitializationOptions) { }
|
||||
|
||||
get readOnly(): Resource['readOnly'] {
|
||||
return this.options?.readOnly;
|
||||
}
|
||||
|
||||
get autosaveable(): boolean {
|
||||
return this.options?.autosaveable !== false;
|
||||
}
|
||||
|
||||
get initiallyDirty(): boolean {
|
||||
return !!this.options?.initiallyDirty;
|
||||
}
|
||||
|
||||
get contents(): string | Promise<string> {
|
||||
return this.options?.contents ?? '';
|
||||
}
|
||||
|
||||
readContents(): Promise<string> {
|
||||
return Promise.resolve(this.options?.contents ?? '');
|
||||
}
|
||||
|
||||
async saveContents(contents: string): Promise<void> {
|
||||
await this.options?.onSave?.(contents);
|
||||
this.update({ contents });
|
||||
}
|
||||
|
||||
update(options: ResourceInitializationOptions): void {
|
||||
const didContentsChange = 'contents' in options && options.contents !== this.options?.contents;
|
||||
const didReadOnlyChange = 'readOnly' in options && options.readOnly !== this.options?.readOnly;
|
||||
this.options = { ...this.options, ...options };
|
||||
if (didContentsChange) {
|
||||
this.onDidChangeContentsEmitter.fire();
|
||||
}
|
||||
if (didReadOnlyChange) {
|
||||
this.onDidChangeReadonlyEmitter.fire(this.readOnly ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.onDidChangeContentsEmitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurableMutableReferenceResource implements Resource {
|
||||
constructor(protected reference: Reference<ConfigurableMutableResource>) { }
|
||||
|
||||
get uri(): URI {
|
||||
return this.reference.object.uri;
|
||||
}
|
||||
|
||||
get onDidChangeContents(): Event<void> {
|
||||
return this.reference.object.onDidChangeContents;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.reference.dispose();
|
||||
}
|
||||
|
||||
readContents(): Promise<string> {
|
||||
return this.reference.object.readContents();
|
||||
}
|
||||
|
||||
saveContents(contents: string): Promise<void> {
|
||||
return this.reference.object.saveContents(contents);
|
||||
}
|
||||
|
||||
update(options: ResourceInitializationOptions): void {
|
||||
this.reference.object.update(options);
|
||||
}
|
||||
|
||||
get readOnly(): Resource['readOnly'] {
|
||||
return this.reference.object.readOnly;
|
||||
}
|
||||
|
||||
get initiallyDirty(): boolean {
|
||||
return this.reference.object.initiallyDirty;
|
||||
}
|
||||
|
||||
get autosaveable(): boolean {
|
||||
return this.reference.object.autosaveable;
|
||||
}
|
||||
|
||||
get contents(): string | Promise<string> {
|
||||
return this.reference.object.contents;
|
||||
}
|
||||
}
|
||||
37
packages/ai-core/src/common/index.ts
Normal file
37
packages/ai-core/src/common/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
export * from './agent-service';
|
||||
export * from './agent';
|
||||
export * from './agents-variable-contribution';
|
||||
export * from './ai-core-preferences';
|
||||
export * from './tool-invocation-registry';
|
||||
export * from './language-model-delegate';
|
||||
export * from './language-model-util';
|
||||
export * from './language-model';
|
||||
export * from './language-model-alias';
|
||||
export * from './prompt-service';
|
||||
export * from './prompt-service-util';
|
||||
export * from './prompt-text';
|
||||
export * from './protocol';
|
||||
export * from './today-variable-contribution';
|
||||
export * from './variable-service';
|
||||
export * from './settings-service';
|
||||
export * from './language-model-service';
|
||||
export * from './token-usage-service';
|
||||
export * from './ai-variable-resource';
|
||||
export * from './configurable-in-memory-resources';
|
||||
export * from './notification-types';
|
||||
export * from './skill';
|
||||
76
packages/ai-core/src/common/language-model-alias.ts
Normal file
76
packages/ai-core/src/common/language-model-alias.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024-2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Event } from '@theia/core';
|
||||
|
||||
/**
|
||||
* Represents an alias for a language model, allowing fallback and selection.
|
||||
*/
|
||||
export interface LanguageModelAlias {
|
||||
/**
|
||||
* The unique identifier for the alias.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The list of default model IDs to use if no selectedModelId is set.
|
||||
* Ordered by priority. The first entry also serves as fallback.
|
||||
*/
|
||||
defaultModelIds: string[];
|
||||
/**
|
||||
* A human-readable description of the alias.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The currently selected model ID, if any.
|
||||
*/
|
||||
selectedModelId?: string;
|
||||
}
|
||||
|
||||
export const LanguageModelAliasRegistry = Symbol('LanguageModelAliasRegistry');
|
||||
/**
|
||||
* Registry for managing language model aliases.
|
||||
*/
|
||||
export interface LanguageModelAliasRegistry {
|
||||
/**
|
||||
* Promise that resolves when the registry is ready for use (preferences loaded).
|
||||
*/
|
||||
ready: Promise<void>;
|
||||
|
||||
/**
|
||||
* Event that is fired when the alias list changes.
|
||||
*/
|
||||
onDidChange: Event<void>;
|
||||
/**
|
||||
* Add a new alias or update an existing one.
|
||||
*/
|
||||
addAlias(alias: LanguageModelAlias): void;
|
||||
/**
|
||||
* Remove an alias by its id.
|
||||
*/
|
||||
removeAlias(id: string): void;
|
||||
/**
|
||||
* Get all aliases.
|
||||
*/
|
||||
getAliases(): LanguageModelAlias[];
|
||||
/**
|
||||
* Resolve an alias or model id to a prioritized list of model ids.
|
||||
* If the id is not an alias, returns [id].
|
||||
* If the alias exists and has a selectedModelId, returns [selectedModelId].
|
||||
* If the alias exists and has no selectedModelId, returns defaultModelIds.
|
||||
* If the alias does not exist, returns undefined.
|
||||
*/
|
||||
resolveAlias(id: string): string[] | undefined;
|
||||
}
|
||||
49
packages/ai-core/src/common/language-model-delegate.ts
Normal file
49
packages/ai-core/src/common/language-model-delegate.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { CancellationToken } from '@theia/core';
|
||||
import {
|
||||
LanguageModelMetaData, LanguageModelParsedResponse, LanguageModelRequest, LanguageModelStreamResponsePart,
|
||||
LanguageModelTextResponse, ToolCallResult
|
||||
} from './language-model';
|
||||
|
||||
export const LanguageModelDelegateClient = Symbol('LanguageModelDelegateClient');
|
||||
export interface LanguageModelDelegateClient {
|
||||
toolCall(requestId: string, toolId: string, args_string: string, toolCallId?: string): Promise<ToolCallResult>;
|
||||
send(id: string, token: LanguageModelStreamResponsePart | undefined): void;
|
||||
error(id: string, error: Error): void;
|
||||
}
|
||||
export const LanguageModelRegistryFrontendDelegate = Symbol('LanguageModelRegistryFrontendDelegate');
|
||||
export interface LanguageModelRegistryFrontendDelegate {
|
||||
getLanguageModelDescriptions(): Promise<LanguageModelMetaData[]>;
|
||||
}
|
||||
|
||||
export interface LanguageModelStreamResponseDelegate {
|
||||
streamId: string;
|
||||
}
|
||||
export const isLanguageModelStreamResponseDelegate = (obj: unknown): obj is LanguageModelStreamResponseDelegate =>
|
||||
!!(obj && typeof obj === 'object' && 'streamId' in obj && typeof (obj as { streamId: unknown }).streamId === 'string');
|
||||
|
||||
export type LanguageModelResponseDelegate = LanguageModelTextResponse | LanguageModelParsedResponse | LanguageModelStreamResponseDelegate;
|
||||
|
||||
export const LanguageModelFrontendDelegate = Symbol('LanguageModelFrontendDelegate');
|
||||
export interface LanguageModelFrontendDelegate {
|
||||
cancel(requestId: string): void;
|
||||
request(modelId: string, request: LanguageModelRequest, requestId: string, cancellationToken?: CancellationToken): Promise<LanguageModelResponseDelegate>;
|
||||
}
|
||||
|
||||
export const languageModelRegistryDelegatePath = '/services/languageModelRegistryDelegatePath';
|
||||
export const languageModelDelegatePath = '/services/languageModelDelegatePath';
|
||||
@@ -0,0 +1,98 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 STMicroelectronics and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import {
|
||||
LanguageModelRequest,
|
||||
LanguageModelResponse,
|
||||
LanguageModelStreamResponse,
|
||||
LanguageModelStreamResponsePart,
|
||||
} from './language-model';
|
||||
|
||||
/**
|
||||
* A session tracking raw exchanges with language models, organized into exchange units.
|
||||
*/
|
||||
export interface LanguageModelSession {
|
||||
/**
|
||||
* Identifier of this Language Model Session. Corresponds to Chat session ids
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* All exchange units part of this session
|
||||
*/
|
||||
exchanges: LanguageModelExchange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An exchange unit representing a logical operation which may involve multiple model requests.
|
||||
*/
|
||||
export interface LanguageModelExchange {
|
||||
/**
|
||||
* Identifier of the exchange unit.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* All requests that constitute this exchange
|
||||
*/
|
||||
requests: LanguageModelExchangeRequest[];
|
||||
/**
|
||||
* Arbitrary metadata for the exchange
|
||||
*/
|
||||
metadata: {
|
||||
agent?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative to the LanguageModelStreamResponse, suited for inspection
|
||||
*/
|
||||
export interface LanguageModelMonitoredStreamResponse {
|
||||
parts: LanguageModelStreamResponsePart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative to the LanguageModelResponse, suited for inspection
|
||||
*/
|
||||
export type LanguageModelExchangeRequestResponse = Exclude<LanguageModelResponse, LanguageModelStreamResponse> | LanguageModelMonitoredStreamResponse;
|
||||
|
||||
/**
|
||||
* Represents a request to a language model within an exchange unit, capturing the request and its response.
|
||||
*/
|
||||
export interface LanguageModelExchangeRequest {
|
||||
/**
|
||||
* Identifier of the request. Might share the id with the parent exchange if there's only one request.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The actual request sent to the language model
|
||||
*/
|
||||
request: LanguageModelRequest;
|
||||
/**
|
||||
* Arbitrary metadata for the request. Might contain an agent id and timestamp.
|
||||
*/
|
||||
metadata: {
|
||||
agent?: string;
|
||||
timestamp?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* The identifier of the language model the request was sent to
|
||||
*/
|
||||
languageModel: string;
|
||||
/**
|
||||
* The recorded response
|
||||
*/
|
||||
response: LanguageModelExchangeRequestResponse;
|
||||
}
|
||||
174
packages/ai-core/src/common/language-model-service.ts
Normal file
174
packages/ai-core/src/common/language-model-service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject } from '@theia/core/shared/inversify';
|
||||
import { isLanguageModelStreamResponse, LanguageModel, LanguageModelRegistry, LanguageModelResponse, LanguageModelStreamResponsePart, UserRequest } from './language-model';
|
||||
import { LanguageModelExchangeRequest, LanguageModelSession } from './language-model-interaction-model';
|
||||
import { Emitter } from '@theia/core';
|
||||
|
||||
export interface RequestAddedEvent {
|
||||
type: 'requestAdded',
|
||||
id: string;
|
||||
}
|
||||
export interface ResponseCompletedEvent {
|
||||
type: 'responseCompleted',
|
||||
requestId: string;
|
||||
}
|
||||
export interface SessionsClearedEvent {
|
||||
type: 'sessionsCleared'
|
||||
}
|
||||
export type SessionEvent = RequestAddedEvent | ResponseCompletedEvent | SessionsClearedEvent;
|
||||
|
||||
export const LanguageModelService = Symbol('LanguageModelService');
|
||||
export interface LanguageModelService {
|
||||
onSessionChanged: Emitter<SessionEvent>['event'];
|
||||
/**
|
||||
* Collection of all recorded LanguageModelSessions.
|
||||
*/
|
||||
sessions: LanguageModelSession[];
|
||||
/**
|
||||
* Submit a language model request, it will automatically be recorded within a LanguageModelSession.
|
||||
*/
|
||||
sendRequest(
|
||||
languageModel: LanguageModel,
|
||||
languageModelRequest: UserRequest
|
||||
): Promise<LanguageModelResponse>;
|
||||
}
|
||||
export class LanguageModelServiceImpl implements LanguageModelService {
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
protected languageModelRegistry: LanguageModelRegistry;
|
||||
|
||||
private _sessions: LanguageModelSession[] = [];
|
||||
|
||||
get sessions(): LanguageModelSession[] {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
set sessions(newSessions: LanguageModelSession[]) {
|
||||
this._sessions = newSessions;
|
||||
if (newSessions.length === 0) {
|
||||
this.sessionChangedEmitter.fire({ type: 'sessionsCleared' });
|
||||
}
|
||||
}
|
||||
|
||||
protected sessionChangedEmitter = new Emitter<SessionEvent>();
|
||||
onSessionChanged = this.sessionChangedEmitter.event;
|
||||
|
||||
async sendRequest(
|
||||
languageModel: LanguageModel,
|
||||
languageModelRequest: UserRequest
|
||||
): Promise<LanguageModelResponse> {
|
||||
// Filter messages based on client settings
|
||||
languageModelRequest.messages = languageModelRequest.messages.filter(message => {
|
||||
if (message.type === 'thinking' && languageModelRequest.clientSettings?.keepThinking === false) {
|
||||
return false;
|
||||
}
|
||||
if ((message.type === 'tool_result' || message.type === 'tool_use') &&
|
||||
languageModelRequest.clientSettings?.keepToolCalls === false) {
|
||||
return false;
|
||||
}
|
||||
// Keep all other messages
|
||||
return true;
|
||||
});
|
||||
|
||||
let response = await languageModel.request(languageModelRequest, languageModelRequest.cancellationToken);
|
||||
let storedResponse: LanguageModelExchangeRequest['response'];
|
||||
if (isLanguageModelStreamResponse(response)) {
|
||||
const parts: LanguageModelStreamResponsePart[] = [];
|
||||
response = {
|
||||
...response,
|
||||
stream: createLoggingAsyncIterable(response.stream,
|
||||
parts,
|
||||
() => this.sessionChangedEmitter.fire({ type: 'responseCompleted', requestId: languageModelRequest.subRequestId ?? languageModelRequest.requestId }))
|
||||
};
|
||||
storedResponse = { parts };
|
||||
} else {
|
||||
storedResponse = response;
|
||||
}
|
||||
this.storeRequest(languageModel, languageModelRequest, storedResponse);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
protected storeRequest(languageModel: LanguageModel, languageModelRequest: UserRequest, response: LanguageModelExchangeRequest['response']): void {
|
||||
// Find or create the session for this request
|
||||
let session = this._sessions.find(s => s.id === languageModelRequest.sessionId);
|
||||
if (!session) {
|
||||
session = {
|
||||
id: languageModelRequest.sessionId,
|
||||
exchanges: []
|
||||
};
|
||||
this._sessions.push(session);
|
||||
}
|
||||
|
||||
// Find or create the exchange for this request
|
||||
let exchange = session.exchanges.find(r => r.id === languageModelRequest.requestId);
|
||||
if (!exchange) {
|
||||
exchange = {
|
||||
id: languageModelRequest.requestId,
|
||||
requests: [],
|
||||
metadata: { agent: languageModelRequest.agentId }
|
||||
};
|
||||
session.exchanges.push(exchange);
|
||||
}
|
||||
|
||||
// Create and add the LanguageModelExchangeRequest to the exchange
|
||||
const exchangeRequest: LanguageModelExchangeRequest = {
|
||||
id: languageModelRequest.subRequestId ?? languageModelRequest.requestId,
|
||||
request: languageModelRequest,
|
||||
languageModel: languageModel.id,
|
||||
response: response,
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
exchange.requests.push(exchangeRequest);
|
||||
|
||||
exchangeRequest.metadata.agent = languageModelRequest.agentId;
|
||||
exchangeRequest.metadata.timestamp = Date.now();
|
||||
if (languageModelRequest.promptVariantId) {
|
||||
exchangeRequest.metadata.promptVariantId = languageModelRequest.promptVariantId;
|
||||
}
|
||||
if (languageModelRequest.isPromptVariantCustomized !== undefined) {
|
||||
exchangeRequest.metadata.isPromptVariantCustomized = languageModelRequest.isPromptVariantCustomized;
|
||||
}
|
||||
|
||||
this.sessionChangedEmitter.fire({ type: 'requestAdded', id: languageModelRequest.subRequestId ?? languageModelRequest.requestId });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AsyncIterable wrapper that stores each yielded item while preserving the
|
||||
* original AsyncIterable behavior.
|
||||
*/
|
||||
async function* createLoggingAsyncIterable(
|
||||
stream: AsyncIterable<LanguageModelStreamResponsePart>,
|
||||
parts: LanguageModelStreamResponsePart[],
|
||||
streamFinished: () => void
|
||||
): AsyncIterable<LanguageModelStreamResponsePart> {
|
||||
try {
|
||||
for await (const part of stream) {
|
||||
parts.push(part);
|
||||
yield part;
|
||||
}
|
||||
} catch (error) {
|
||||
parts.push({ content: `[NOT FROM LLM] An error occurred: ${error.message}` });
|
||||
throw error;
|
||||
} finally {
|
||||
streamFinished();
|
||||
}
|
||||
}
|
||||
82
packages/ai-core/src/common/language-model-util.ts
Normal file
82
packages/ai-core/src/common/language-model-util.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
isLanguageModelParsedResponse,
|
||||
isLanguageModelStreamResponse,
|
||||
isLanguageModelTextResponse,
|
||||
isTextResponsePart,
|
||||
LanguageModelResponse,
|
||||
ToolRequest
|
||||
} from './language-model';
|
||||
import { LanguageModelMonitoredStreamResponse } from './language-model-interaction-model';
|
||||
|
||||
/**
|
||||
* Retrieves the text content from a `LanguageModelResponse` object.
|
||||
*
|
||||
* **Important:** For stream responses, the stream can only be consumed once. Calling this function multiple times on the same stream response will return an empty string (`''`)
|
||||
* on subsequent calls, as the stream will have already been consumed.
|
||||
*
|
||||
* @param {LanguageModelResponse} response - The response object, which may contain a text, stream, or parsed response.
|
||||
* @returns {Promise<string>} - A promise that resolves to the text content of the response.
|
||||
* @throws {Error} - Throws an error if the response type is not supported or does not contain valid text content.
|
||||
*/
|
||||
export const getTextOfResponse = async (response: LanguageModelResponse | LanguageModelMonitoredStreamResponse): Promise<string> => {
|
||||
if (isLanguageModelTextResponse(response)) {
|
||||
return response.text;
|
||||
} else if (isLanguageModelStreamResponse(response)) {
|
||||
let result = '';
|
||||
for await (const chunk of response.stream) {
|
||||
result += (isTextResponsePart(chunk) && chunk.content) ? chunk.content : '';
|
||||
}
|
||||
return result;
|
||||
} else if (isLanguageModelParsedResponse(response)) {
|
||||
return response.content;
|
||||
} else if ('parts' in response) {
|
||||
// Handle monitored stream response
|
||||
let result = '';
|
||||
for (const chunk of response.parts) {
|
||||
result += (isTextResponsePart(chunk) && chunk.content) ? chunk.content : '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
throw new Error(`Invalid response type ${response}`);
|
||||
};
|
||||
|
||||
export const getJsonOfResponse = async (response: LanguageModelResponse | LanguageModelMonitoredStreamResponse): Promise<unknown> => {
|
||||
const text = await getTextOfResponse(response);
|
||||
return getJsonOfText(text);
|
||||
};
|
||||
|
||||
export const getJsonOfText = (text: string): unknown => {
|
||||
if (text.startsWith('```json')) {
|
||||
const regex = /```json\s*([\s\S]*?)\s*```/g;
|
||||
let match;
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
try {
|
||||
return JSON.parse(match[1]);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JSON:', error);
|
||||
}
|
||||
}
|
||||
} else if (text.startsWith('{') || text.startsWith('[')) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
throw new Error('Invalid response format');
|
||||
};
|
||||
|
||||
export const toolRequestToPromptText = (toolRequest: ToolRequest): string => `${toolRequest.id}`;
|
||||
86
packages/ai-core/src/common/language-model.spec.ts
Normal file
86
packages/ai-core/src/common/language-model.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { isModelMatching, LanguageModel, LanguageModelSelector } from './language-model';
|
||||
import { expect } from 'chai';
|
||||
|
||||
describe('isModelMatching', () => {
|
||||
it('returns false with one of two parameter mismatches', () => {
|
||||
expect(
|
||||
isModelMatching(
|
||||
<LanguageModelSelector>{
|
||||
name: 'XXX',
|
||||
family: 'YYY',
|
||||
},
|
||||
<LanguageModel>{
|
||||
name: 'gpt-4o',
|
||||
family: 'YYY',
|
||||
}
|
||||
)
|
||||
).eql(false);
|
||||
});
|
||||
it('returns false with two parameter mismatches', () => {
|
||||
expect(
|
||||
isModelMatching(
|
||||
<LanguageModelSelector>{
|
||||
name: 'XXX',
|
||||
family: 'YYY',
|
||||
},
|
||||
<LanguageModel>{
|
||||
name: 'gpt-4o',
|
||||
family: 'ZZZ',
|
||||
}
|
||||
)
|
||||
).eql(false);
|
||||
});
|
||||
it('returns true with one parameter match', () => {
|
||||
expect(
|
||||
isModelMatching(
|
||||
<LanguageModelSelector>{
|
||||
name: 'gpt-4o',
|
||||
},
|
||||
<LanguageModel>{
|
||||
name: 'gpt-4o',
|
||||
}
|
||||
)
|
||||
).eql(true);
|
||||
});
|
||||
it('returns true with two parameter matches', () => {
|
||||
expect(
|
||||
isModelMatching(
|
||||
<LanguageModelSelector>{
|
||||
name: 'gpt-4o',
|
||||
family: 'YYY',
|
||||
},
|
||||
<LanguageModel>{
|
||||
name: 'gpt-4o',
|
||||
family: 'YYY',
|
||||
}
|
||||
)
|
||||
).eql(true);
|
||||
});
|
||||
it('returns true if there are no parameters in selector', () => {
|
||||
expect(
|
||||
isModelMatching(
|
||||
<LanguageModelSelector>{},
|
||||
<LanguageModel>{
|
||||
name: 'gpt-4o',
|
||||
family: 'YYY',
|
||||
}
|
||||
)
|
||||
).eql(true);
|
||||
});
|
||||
});
|
||||
570
packages/ai-core/src/common/language-model.ts
Normal file
570
packages/ai-core/src/common/language-model.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024-2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContributionProvider, ILogger, isFunction, isObject, Event, Emitter, CancellationToken } from '@theia/core';
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
|
||||
export type MessageActor = 'user' | 'ai' | 'system';
|
||||
|
||||
export type LanguageModelMessage = TextMessage | ThinkingMessage | ToolUseMessage | ToolResultMessage | ImageMessage;
|
||||
export namespace LanguageModelMessage {
|
||||
|
||||
export function isTextMessage(obj: LanguageModelMessage): obj is TextMessage {
|
||||
return obj.type === 'text';
|
||||
}
|
||||
export function isThinkingMessage(obj: LanguageModelMessage): obj is ThinkingMessage {
|
||||
return obj.type === 'thinking';
|
||||
}
|
||||
export function isToolUseMessage(obj: LanguageModelMessage): obj is ToolUseMessage {
|
||||
return obj.type === 'tool_use';
|
||||
}
|
||||
export function isToolResultMessage(obj: LanguageModelMessage): obj is ToolResultMessage {
|
||||
return obj.type === 'tool_result';
|
||||
}
|
||||
export function isImageMessage(obj: LanguageModelMessage): obj is ImageMessage {
|
||||
return obj.type === 'image';
|
||||
}
|
||||
}
|
||||
export interface TextMessage {
|
||||
actor: MessageActor;
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
export interface ThinkingMessage {
|
||||
actor: 'ai'
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface ToolResultMessage {
|
||||
actor: 'user';
|
||||
tool_use_id: string;
|
||||
name: string;
|
||||
type: 'tool_result';
|
||||
content?: ToolCallResult;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolUseMessage {
|
||||
actor: 'ai';
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
input: unknown;
|
||||
name: string;
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
export type ImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | 'image/bmp' | 'image/svg+xml' | string & {};
|
||||
export interface UrlImageContent { url: string };
|
||||
export interface Base64ImageContent {
|
||||
base64data: string;
|
||||
mimeType: ImageMimeType;
|
||||
};
|
||||
export type ImageContent = UrlImageContent | Base64ImageContent;
|
||||
export namespace ImageContent {
|
||||
export const isUrl = (obj: ImageContent): obj is UrlImageContent => 'url' in obj;
|
||||
export const isBase64 = (obj: ImageContent): obj is Base64ImageContent => 'base64data' in obj && 'mimeType' in obj;
|
||||
}
|
||||
export interface ImageMessage {
|
||||
actor: 'ai' | 'user';
|
||||
type: 'image';
|
||||
image: ImageContent;
|
||||
}
|
||||
|
||||
export const isLanguageModelRequestMessage = (obj: unknown): obj is LanguageModelMessage =>
|
||||
!!(obj && typeof obj === 'object' &&
|
||||
'type' in obj &&
|
||||
typeof (obj as { type: unknown }).type === 'string' &&
|
||||
(obj as { type: unknown }).type === 'text' &&
|
||||
'query' in obj &&
|
||||
typeof (obj as { query: unknown }).query === 'string'
|
||||
);
|
||||
|
||||
export interface ToolRequestParameterProperty {
|
||||
type?: | 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
||||
anyOf?: ToolRequestParameterProperty[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ToolRequestParametersProperties = Record<string, ToolRequestParameterProperty>;
|
||||
export interface ToolRequestParameters {
|
||||
type?: 'object';
|
||||
properties: ToolRequestParametersProperties;
|
||||
required?: string[];
|
||||
}
|
||||
/**
|
||||
* Defines a tool that can be invoked by language models.
|
||||
* @typeParam TContext - The context type passed to the handler. Defaults to ToolInvocationContext.
|
||||
*/
|
||||
export interface ToolRequest<TContext extends ToolInvocationContext = ToolInvocationContext> {
|
||||
id: string;
|
||||
name: string;
|
||||
parameters: ToolRequestParameters
|
||||
description?: string;
|
||||
handler: (arg_string: string, ctx?: TContext) => Promise<ToolCallResult>;
|
||||
providerName?: string;
|
||||
|
||||
/**
|
||||
* If set, this tool requires extra confirmation before auto-approval can be enabled.
|
||||
*
|
||||
* When a tool has this flag:
|
||||
* - It defaults to CONFIRM mode (not ALWAYS_ALLOW) even if global default is ALWAYS_ALLOW
|
||||
* - When user selects "Always Allow", an extra confirmation modal is shown
|
||||
* - The modal displays a warning about the tool's capabilities
|
||||
*
|
||||
* If a string is provided, it will be displayed as the custom warning message.
|
||||
* If true, a generic warning message will be shown.
|
||||
*
|
||||
* Use for tools with broad system access (shell execution, file deletion, etc.)
|
||||
*/
|
||||
confirmAlwaysAllow?: boolean | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to tool handlers during invocation by language models.
|
||||
* Language models should pass this context when invoking tool handlers to enable
|
||||
* proper tracking and correlation of tool calls.
|
||||
*/
|
||||
export interface ToolInvocationContext {
|
||||
/**
|
||||
* The unique identifier for this specific tool call invocation.
|
||||
* This ID is assigned by the language model and used to correlate
|
||||
* the tool call with its response.
|
||||
*/
|
||||
toolCallId?: string;
|
||||
/**
|
||||
* Optional cancellation token to support cancelling tool execution.
|
||||
*/
|
||||
cancellationToken?: CancellationToken;
|
||||
}
|
||||
|
||||
export namespace ToolInvocationContext {
|
||||
export function is(obj: unknown): obj is ToolInvocationContext {
|
||||
return !!obj && typeof obj === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ToolInvocationContext with the given tool call ID and optional cancellation token.
|
||||
*/
|
||||
export function create(toolCallId?: string, cancellationToken?: CancellationToken): ToolInvocationContext {
|
||||
return { toolCallId, cancellationToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the tool call ID from an unknown context object.
|
||||
* Returns undefined if the context is not a valid ToolInvocationContext or has no toolCallId.
|
||||
*/
|
||||
export function getToolCallId(ctx: unknown): string | undefined {
|
||||
if (is(ctx) && 'toolCallId' in ctx && typeof ctx.toolCallId === 'string') {
|
||||
return ctx.toolCallId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the cancellation token from an unknown context object.
|
||||
*/
|
||||
export function getCancellationToken(ctx: unknown): CancellationToken | undefined {
|
||||
if (is(ctx) && 'cancellationToken' in ctx) {
|
||||
return ctx.cancellationToken as CancellationToken | undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ToolRequest {
|
||||
function isToolRequestParameterProperty(obj: unknown): obj is ToolRequestParameterProperty {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
// Check that at least one of "type" or "anyOf" exists
|
||||
if (!('type' in record) && !('anyOf' in record)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If an "anyOf" field is present, it must be an array where each item is also a valid property.
|
||||
if ('anyOf' in record) {
|
||||
if (!Array.isArray(record.anyOf)) {
|
||||
return false;
|
||||
}
|
||||
for (const item of record.anyOf) {
|
||||
if (!isToolRequestParameterProperty(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('type' in record && typeof record.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No further checks required for additional properties.
|
||||
return true;
|
||||
}
|
||||
export function isToolRequestParametersProperties(obj: unknown): obj is ToolRequestParametersProperties {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(obj).every(([key, value]) => {
|
||||
if (typeof key !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return isToolRequestParameterProperty(value);
|
||||
});
|
||||
}
|
||||
export function isToolRequestParameters(obj: unknown): obj is ToolRequestParameters {
|
||||
return !!obj && typeof obj === 'object' &&
|
||||
(!('type' in obj) || obj.type === 'object') &&
|
||||
'properties' in obj && isToolRequestParametersProperties(obj.properties) &&
|
||||
(!('required' in obj) || (Array.isArray(obj.required) && obj.required.every(prop => typeof prop === 'string')));
|
||||
}
|
||||
}
|
||||
export interface LanguageModelRequest {
|
||||
messages: LanguageModelMessage[],
|
||||
tools?: ToolRequest[];
|
||||
response_format?: { type: 'text' } | { type: 'json_object' } | ResponseFormatJsonSchema;
|
||||
settings?: { [key: string]: unknown };
|
||||
clientSettings?: { keepToolCalls: boolean; keepThinking: boolean }
|
||||
}
|
||||
export interface ResponseFormatJsonSchema {
|
||||
type: 'json_schema';
|
||||
json_schema: {
|
||||
name: string,
|
||||
description?: string,
|
||||
schema?: Record<string, unknown>,
|
||||
strict?: boolean | null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The UserRequest extends the "pure" LanguageModelRequest for cancelling support as well as
|
||||
* logging metadata.
|
||||
* The additional metadata might also be used for other use cases, for example to query default
|
||||
* request settings based on the agent id, merging with the request settings handed over.
|
||||
*/
|
||||
export interface UserRequest extends LanguageModelRequest {
|
||||
/**
|
||||
* Identifier of the Ai/ChatSession
|
||||
*/
|
||||
sessionId: string;
|
||||
/**
|
||||
* Identifier of the request or overall exchange. Corresponds to request id in Chat sessions
|
||||
*/
|
||||
requestId: string;
|
||||
/**
|
||||
* Id of a request in case a single exchange consists of multiple requests. In this case the requestId corresponds to the overall exchange.
|
||||
*/
|
||||
subRequestId?: string;
|
||||
/**
|
||||
* Optional agent identifier in case the request was sent by an agent
|
||||
*/
|
||||
agentId?: string;
|
||||
/**
|
||||
* Optional prompt variant ID used for this request
|
||||
*/
|
||||
promptVariantId?: string;
|
||||
/**
|
||||
* Indicates whether the prompt variant was customized
|
||||
*/
|
||||
isPromptVariantCustomized?: boolean;
|
||||
/**
|
||||
* Cancellation support
|
||||
*/
|
||||
cancellationToken?: CancellationToken;
|
||||
}
|
||||
|
||||
export interface LanguageModelTextResponse {
|
||||
text: string;
|
||||
}
|
||||
export const isLanguageModelTextResponse = (obj: unknown): obj is LanguageModelTextResponse =>
|
||||
!!(obj && typeof obj === 'object' && 'text' in obj && typeof (obj as { text: unknown }).text === 'string');
|
||||
|
||||
export type LanguageModelStreamResponsePart = TextResponsePart | ToolCallResponsePart | ThinkingResponsePart | UsageResponsePart;
|
||||
|
||||
export const isLanguageModelStreamResponsePart = (part: unknown): part is LanguageModelStreamResponsePart =>
|
||||
isUsageResponsePart(part) || isTextResponsePart(part) || isThinkingResponsePart(part) || isToolCallResponsePart(part);
|
||||
|
||||
export interface UsageResponsePart {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
export const isUsageResponsePart = (part: unknown): part is UsageResponsePart =>
|
||||
!!(part && typeof part === 'object' &&
|
||||
'input_tokens' in part && typeof part.input_tokens === 'number' &&
|
||||
'output_tokens' in part && typeof part.output_tokens === 'number');
|
||||
export interface TextResponsePart {
|
||||
content: string;
|
||||
}
|
||||
export const isTextResponsePart = (part: unknown): part is TextResponsePart =>
|
||||
!!(part && typeof part === 'object' && 'content' in part && typeof part.content === 'string');
|
||||
|
||||
export interface ToolCallResponsePart {
|
||||
tool_calls: ToolCall[];
|
||||
}
|
||||
export const isToolCallResponsePart = (part: unknown): part is ToolCallResponsePart =>
|
||||
!!(part && typeof part === 'object' && 'tool_calls' in part && Array.isArray(part.tool_calls));
|
||||
|
||||
export interface ThinkingResponsePart {
|
||||
thought: string;
|
||||
signature: string;
|
||||
}
|
||||
export const isThinkingResponsePart = (part: unknown): part is ThinkingResponsePart =>
|
||||
!!(part && typeof part === 'object' && 'thought' in part && typeof part.thought === 'string');
|
||||
|
||||
export interface ToolCallTextResult { type: 'text', text: string; };
|
||||
export interface ToolCallImageResult extends Base64ImageContent { type: 'image' };
|
||||
export interface ToolCallAudioResult { type: 'audio', data: string; mimeType: string };
|
||||
export type ToolCallErrorKind = 'tool-not-available';
|
||||
export interface ToolCallErrorResult { type: 'error', data: string; errorKind?: ToolCallErrorKind; };
|
||||
export type ToolCallContentResult = ToolCallTextResult | ToolCallImageResult | ToolCallAudioResult | ToolCallErrorResult;
|
||||
export interface ToolCallContent {
|
||||
content: ToolCallContentResult[];
|
||||
}
|
||||
|
||||
export const isToolCallContent = (result: unknown): result is ToolCallContent =>
|
||||
!!(result && typeof result === 'object' && 'content' in result && Array.isArray((result as ToolCallContent).content));
|
||||
|
||||
export const isToolCallErrorResult = (item: unknown): item is ToolCallErrorResult =>
|
||||
!!(item && typeof item === 'object' && 'type' in item && (item as ToolCallErrorResult).type === 'error' && 'data' in item);
|
||||
|
||||
export const isToolNotAvailableError = (item: unknown): item is ToolCallErrorResult =>
|
||||
isToolCallErrorResult(item) && item.errorKind === 'tool-not-available';
|
||||
|
||||
export const hasToolCallError = (result: ToolCallResult): boolean =>
|
||||
isToolCallContent(result) && result.content.some(isToolCallErrorResult);
|
||||
|
||||
export const hasToolNotAvailableError = (result: ToolCallResult): boolean =>
|
||||
isToolCallContent(result) && result.content.some(isToolNotAvailableError);
|
||||
|
||||
export const createToolCallError = (message: string, errorKind?: ToolCallErrorKind): ToolCallContent => ({
|
||||
content: [errorKind ? { type: 'error', data: message, errorKind } : { type: 'error', data: message }]
|
||||
});
|
||||
|
||||
export type ToolCallResult = undefined | object | string | ToolCallContent;
|
||||
export interface ToolCall {
|
||||
id?: string;
|
||||
function?: {
|
||||
arguments?: string;
|
||||
name?: string;
|
||||
},
|
||||
finished?: boolean;
|
||||
result?: ToolCallResult;
|
||||
data?: Record<string, string>;
|
||||
/**
|
||||
* When true, the arguments field contains a delta to be appended
|
||||
* to existing arguments rather than a complete replacement.
|
||||
*/
|
||||
argumentsDelta?: boolean;
|
||||
}
|
||||
|
||||
export interface LanguageModelStreamResponse {
|
||||
stream: AsyncIterable<LanguageModelStreamResponsePart>;
|
||||
}
|
||||
export const isLanguageModelStreamResponse = (obj: unknown): obj is LanguageModelStreamResponse =>
|
||||
!!(obj && typeof obj === 'object' && 'stream' in obj);
|
||||
|
||||
export interface LanguageModelParsedResponse {
|
||||
parsed: unknown;
|
||||
content: string;
|
||||
}
|
||||
export const isLanguageModelParsedResponse = (obj: unknown): obj is LanguageModelParsedResponse =>
|
||||
!!(obj && typeof obj === 'object' && 'parsed' in obj && 'content' in obj);
|
||||
|
||||
export type LanguageModelResponse = LanguageModelTextResponse | LanguageModelStreamResponse | LanguageModelParsedResponse;
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Language Model Provider
|
||||
///////////////////////////////////////////
|
||||
|
||||
export const LanguageModelProvider = Symbol('LanguageModelProvider');
|
||||
export type LanguageModelProvider = () => Promise<LanguageModel[]>;
|
||||
|
||||
// See also VS Code `ILanguageModelChatMetadata`
|
||||
export interface LanguageModelMetaData {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly vendor?: string;
|
||||
readonly version?: string;
|
||||
readonly family?: string;
|
||||
readonly maxInputTokens?: number;
|
||||
readonly maxOutputTokens?: number;
|
||||
readonly status: LanguageModelStatus;
|
||||
}
|
||||
|
||||
export namespace LanguageModelMetaData {
|
||||
export function is(arg: unknown): arg is LanguageModelMetaData {
|
||||
return isObject(arg) && 'id' in arg;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LanguageModelStatus {
|
||||
status: 'ready' | 'unavailable';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LanguageModel extends LanguageModelMetaData {
|
||||
request(request: UserRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse>;
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export function is(arg: unknown): arg is LanguageModel {
|
||||
return isObject(arg) && 'id' in arg && isFunction(arg.request);
|
||||
}
|
||||
}
|
||||
|
||||
// See also VS Code `ILanguageModelChatSelector`
|
||||
interface VsCodeLanguageModelSelector {
|
||||
readonly identifier?: string;
|
||||
readonly name?: string;
|
||||
readonly vendor?: string;
|
||||
readonly version?: string;
|
||||
readonly family?: string;
|
||||
readonly tokens?: number;
|
||||
}
|
||||
|
||||
export interface LanguageModelSelector extends VsCodeLanguageModelSelector {
|
||||
readonly agent: string;
|
||||
readonly purpose: string;
|
||||
}
|
||||
|
||||
export type LanguageModelRequirement = Omit<LanguageModelSelector, 'agent'>;
|
||||
|
||||
export const LanguageModelRegistry = Symbol('LanguageModelRegistry');
|
||||
|
||||
/**
|
||||
* Base interface for language model registries (frontend and backend).
|
||||
*/
|
||||
export interface LanguageModelRegistry {
|
||||
onChange: Event<{ models: LanguageModel[] }>;
|
||||
addLanguageModels(models: LanguageModel[]): void;
|
||||
getLanguageModels(): Promise<LanguageModel[]>;
|
||||
getLanguageModel(id: string): Promise<LanguageModel | undefined>;
|
||||
removeLanguageModels(id: string[]): void;
|
||||
selectLanguageModel(request: LanguageModelSelector): Promise<LanguageModel | undefined>;
|
||||
selectLanguageModels(request: LanguageModelSelector): Promise<LanguageModel[] | undefined>;
|
||||
patchLanguageModel<T extends LanguageModel = LanguageModel>(id: string, patch: Partial<T>): Promise<void>;
|
||||
}
|
||||
|
||||
export const FrontendLanguageModelRegistry = Symbol('FrontendLanguageModelRegistry');
|
||||
|
||||
/**
|
||||
* Frontend-specific language model registry interface (supports alias resolution).
|
||||
*/
|
||||
export interface FrontendLanguageModelRegistry extends LanguageModelRegistry {
|
||||
/**
|
||||
* If an id of a language model is provded, returns the LanguageModel if it is `ready`.
|
||||
* If an alias is provided, finds the highest-priority ready model from that alias.
|
||||
* If none are ready returns undefined.
|
||||
*/
|
||||
getReadyLanguageModel(idOrAlias: string): Promise<LanguageModel | undefined>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class DefaultLanguageModelRegistryImpl implements LanguageModelRegistry {
|
||||
@inject(ILogger)
|
||||
protected logger: ILogger;
|
||||
@inject(ContributionProvider) @named(LanguageModelProvider)
|
||||
protected readonly languageModelContributions: ContributionProvider<LanguageModelProvider>;
|
||||
|
||||
protected languageModels: LanguageModel[] = [];
|
||||
|
||||
protected markInitialized: () => void;
|
||||
protected initialized: Promise<void> = new Promise(resolve => { this.markInitialized = resolve; });
|
||||
|
||||
protected changeEmitter = new Emitter<{ models: LanguageModel[] }>();
|
||||
onChange = this.changeEmitter.event;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
const contributions = this.languageModelContributions.getContributions();
|
||||
const promises = contributions.map(provider => provider());
|
||||
Promise.allSettled(promises).then(results => {
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
this.languageModels.push(...result.value);
|
||||
} else {
|
||||
this.logger.error('Failed to add some language models:', result.reason);
|
||||
}
|
||||
}
|
||||
this.markInitialized();
|
||||
});
|
||||
}
|
||||
|
||||
addLanguageModels(models: LanguageModel[]): void {
|
||||
models.forEach(model => {
|
||||
if (this.languageModels.find(lm => lm.id === model.id)) {
|
||||
console.warn(`Tried to add already existing language model with id ${model.id}. The new model will be ignored.`);
|
||||
return;
|
||||
}
|
||||
this.languageModels.push(model);
|
||||
this.changeEmitter.fire({ models: this.languageModels });
|
||||
});
|
||||
}
|
||||
|
||||
async getLanguageModels(): Promise<LanguageModel[]> {
|
||||
await this.initialized;
|
||||
return this.languageModels;
|
||||
}
|
||||
|
||||
async getLanguageModel(id: string): Promise<LanguageModel | undefined> {
|
||||
await this.initialized;
|
||||
return this.languageModels.find(model => model.id === id);
|
||||
}
|
||||
|
||||
removeLanguageModels(ids: string[]): void {
|
||||
ids.forEach(id => {
|
||||
const index = this.languageModels.findIndex(model => model.id === id);
|
||||
if (index !== -1) {
|
||||
this.languageModels.splice(index, 1);
|
||||
this.changeEmitter.fire({ models: this.languageModels });
|
||||
} else {
|
||||
console.warn(`Language model with id ${id} was requested to be removed, however it does not exist`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async selectLanguageModels(request: LanguageModelSelector): Promise<LanguageModel[] | undefined> {
|
||||
await this.initialized;
|
||||
// TODO check for actor and purpose against settings
|
||||
return this.languageModels.filter(model => model.status.status === 'ready' && isModelMatching(request, model));
|
||||
}
|
||||
|
||||
async selectLanguageModel(request: LanguageModelSelector): Promise<LanguageModel | undefined> {
|
||||
const models = await this.selectLanguageModels(request);
|
||||
return models ? models[0] : undefined;
|
||||
}
|
||||
|
||||
async patchLanguageModel<T extends LanguageModel = LanguageModel>(id: string, patch: Partial<T>): Promise<void> {
|
||||
await this.initialized;
|
||||
const model = this.languageModels.find(m => m.id === id);
|
||||
if (!model) {
|
||||
this.logger.warn(`Language model with id ${id} not found for patch.`);
|
||||
return;
|
||||
}
|
||||
Object.assign(model, patch);
|
||||
this.changeEmitter.fire({ models: this.languageModels });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function isModelMatching(request: LanguageModelSelector, model: LanguageModel): boolean {
|
||||
return (!request.identifier || model.id === request.identifier) &&
|
||||
(!request.name || model.name === request.name) &&
|
||||
(!request.vendor || model.vendor === request.vendor) &&
|
||||
(!request.version || model.version === request.version) &&
|
||||
(!request.family || model.family === request.family);
|
||||
}
|
||||
31
packages/ai-core/src/common/notification-types.ts
Normal file
31
packages/ai-core/src/common/notification-types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export const NOTIFICATION_TYPE_OFF = 'off';
|
||||
export const NOTIFICATION_TYPE_OS_NOTIFICATION = 'os-notification';
|
||||
export const NOTIFICATION_TYPE_MESSAGE = 'message';
|
||||
export const NOTIFICATION_TYPE_BLINK = 'blink';
|
||||
export type NotificationType =
|
||||
| typeof NOTIFICATION_TYPE_OFF
|
||||
| typeof NOTIFICATION_TYPE_OS_NOTIFICATION
|
||||
| typeof NOTIFICATION_TYPE_MESSAGE
|
||||
| typeof NOTIFICATION_TYPE_BLINK;
|
||||
export const NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NOTIFICATION_TYPE_OFF,
|
||||
NOTIFICATION_TYPE_OS_NOTIFICATION,
|
||||
NOTIFICATION_TYPE_MESSAGE,
|
||||
NOTIFICATION_TYPE_BLINK,
|
||||
];
|
||||
31
packages/ai-core/src/common/prompt-service-util.ts
Normal file
31
packages/ai-core/src/common/prompt-service-util.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
/** Should match the one from VariableResolverService. The format is `{{variableName:arg}}`. We allow {{}} and {{{}}} but no mixtures */
|
||||
export const PROMPT_VARIABLE_TWO_BRACES_REGEX = /(?<!\{)\{\{\s*([^{}]+?)\s*\}\}(?!\})/g;
|
||||
export const PROMPT_VARIABLE_THREE_BRACES_REGEX = /(?<!\{)\{\{\{\s*([^{}]+?)\s*\}\}\}(?!\})/g;
|
||||
export function matchVariablesRegEx(template: string): RegExpMatchArray[] {
|
||||
const twoBraceMatches = [...template.matchAll(PROMPT_VARIABLE_TWO_BRACES_REGEX)];
|
||||
const threeBraceMatches = [...template.matchAll(PROMPT_VARIABLE_THREE_BRACES_REGEX)];
|
||||
return twoBraceMatches.concat(threeBraceMatches);
|
||||
}
|
||||
|
||||
/** Match function/tool references in the prompt. The format is `~{functionId}`. */
|
||||
export const PROMPT_FUNCTION_REGEX = /\~\{\s*(.*?)\s*\}/g;
|
||||
|
||||
export function matchFunctionsRegEx(template: string): RegExpMatchArray[] {
|
||||
return [...template.matchAll(PROMPT_FUNCTION_REGEX)];
|
||||
}
|
||||
508
packages/ai-core/src/common/prompt-service.spec.ts
Normal file
508
packages/ai-core/src/common/prompt-service.spec.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { Container } from 'inversify';
|
||||
import { PromptService, PromptServiceImpl } from './prompt-service';
|
||||
import { DefaultAIVariableService, AIVariableService } from './variable-service';
|
||||
import { ToolInvocationRegistry } from './tool-invocation-registry';
|
||||
import { ToolRequest } from './language-model';
|
||||
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
||||
import { ILogger, Logger } from '@theia/core';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
describe('PromptService', () => {
|
||||
let promptService: PromptService;
|
||||
|
||||
beforeEach(() => {
|
||||
const container = new Container();
|
||||
container.bind<PromptService>(PromptService).to(PromptServiceImpl).inSingletonScope();
|
||||
const logger = sinon.createStubInstance(Logger);
|
||||
|
||||
const variableService = new DefaultAIVariableService({ getContributions: () => [] }, logger);
|
||||
const nameVariable = { id: 'test', name: 'name', description: 'Test name ' };
|
||||
variableService.registerResolver(nameVariable, {
|
||||
canResolve: () => 100,
|
||||
resolve: async () => ({ variable: nameVariable, value: 'Jane' })
|
||||
});
|
||||
container.bind<AIVariableService>(AIVariableService).toConstantValue(variableService);
|
||||
container.bind<ILogger>(ILogger).toConstantValue(new MockLogger);
|
||||
|
||||
promptService = container.get<PromptService>(PromptService);
|
||||
promptService.addBuiltInPromptFragment({ id: '1', template: 'Hello, {{name}}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '2', template: 'Goodbye, {{name}}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '3', template: 'Ciao, {{invalid}}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '8', template: 'Hello, {{{name}}}' });
|
||||
});
|
||||
|
||||
it('should successfully initialize and retrieve built-in prompt fragments', () => {
|
||||
const allPrompts = promptService.getActivePromptFragments();
|
||||
expect(allPrompts.find(prompt => prompt.id === '1')!.template).to.equal('Hello, {{name}}!');
|
||||
expect(allPrompts.find(prompt => prompt.id === '2')!.template).to.equal('Goodbye, {{name}}!');
|
||||
expect(allPrompts.find(prompt => prompt.id === '3')!.template).to.equal('Ciao, {{invalid}}!');
|
||||
expect(allPrompts.find(prompt => prompt.id === '8')!.template).to.equal('Hello, {{{name}}}');
|
||||
});
|
||||
|
||||
it('should retrieve raw prompt fragment by id', () => {
|
||||
const rawPrompt = promptService.getRawPromptFragment('1');
|
||||
expect(rawPrompt?.template).to.equal('Hello, {{name}}!');
|
||||
});
|
||||
|
||||
it('should format prompt fragment with provided arguments', async () => {
|
||||
const formattedPrompt = await promptService.getResolvedPromptFragment('1', { name: 'John' });
|
||||
expect(formattedPrompt?.text).to.equal('Hello, John!');
|
||||
});
|
||||
|
||||
it('should store a new prompt fragment', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: '3', template: 'Welcome, {{name}}!' });
|
||||
const newPrompt = promptService.getRawPromptFragment('3');
|
||||
expect(newPrompt?.template).to.equal('Welcome, {{name}}!');
|
||||
});
|
||||
|
||||
it('should replace variable placeholders with provided arguments', async () => {
|
||||
const prompt = await promptService.getResolvedPromptFragment('1', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hello, John!');
|
||||
});
|
||||
|
||||
it('should use variable service to resolve placeholders when argument values are not provided', async () => {
|
||||
const prompt = await promptService.getResolvedPromptFragment('1');
|
||||
expect(prompt?.text).to.equal('Hello, Jane!');
|
||||
});
|
||||
|
||||
it('should return the prompt fragment even if there are no valid replacements', async () => {
|
||||
const prompt = await promptService.getResolvedPromptFragment('3');
|
||||
expect(prompt?.text).to.equal('Ciao, {{invalid}}!');
|
||||
});
|
||||
|
||||
it('should return undefined if the prompt fragment id is not found', async () => {
|
||||
const prompt = await promptService.getResolvedPromptFragment('4');
|
||||
expect(prompt).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should ignore whitespace in variables', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: '4', template: 'Hello, {{name }}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '5', template: 'Hello, {{ name}}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '6', template: 'Hello, {{ name }}!' });
|
||||
promptService.addBuiltInPromptFragment({ id: '7', template: 'Hello, {{ name }}!' });
|
||||
for (let i = 4; i <= 7; i++) {
|
||||
const prompt = await promptService.getResolvedPromptFragment(`${i}`, { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hello, John!');
|
||||
}
|
||||
});
|
||||
|
||||
it('should retrieve raw prompt fragment by id (three bracket)', () => {
|
||||
const rawPrompt = promptService.getRawPromptFragment('8');
|
||||
expect(rawPrompt?.template).to.equal('Hello, {{{name}}}');
|
||||
});
|
||||
|
||||
it('should correctly replace variables (three brackets)', async () => {
|
||||
const formattedPrompt = await promptService.getResolvedPromptFragment('8');
|
||||
expect(formattedPrompt?.text).to.equal('Hello, Jane');
|
||||
});
|
||||
|
||||
it('should ignore whitespace in variables (three bracket)', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: '9', template: 'Hello, {{{name }}}' });
|
||||
promptService.addBuiltInPromptFragment({ id: '10', template: 'Hello, {{{ name}}}' });
|
||||
promptService.addBuiltInPromptFragment({ id: '11', template: 'Hello, {{{ name }}}' });
|
||||
promptService.addBuiltInPromptFragment({ id: '12', template: 'Hello, {{{ name }}}' });
|
||||
for (let i = 9; i <= 12; i++) {
|
||||
const prompt = await promptService.getResolvedPromptFragment(`${i}`, { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hello, John');
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore invalid prompts with unmatched brackets', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: '9', template: 'Hello, {{name' });
|
||||
promptService.addBuiltInPromptFragment({ id: '10', template: 'Hello, {{{name' });
|
||||
promptService.addBuiltInPromptFragment({ id: '11', template: 'Hello, name}}}}' });
|
||||
const prompt1 = await promptService.getResolvedPromptFragment('9', { name: 'John' });
|
||||
expect(prompt1?.text).to.equal('Hello, {{name'); // Not matching due to missing closing brackets
|
||||
const prompt2 = await promptService.getResolvedPromptFragment('10', { name: 'John' });
|
||||
expect(prompt2?.text).to.equal('Hello, {{{name'); // Matches pattern due to valid three-start-two-end brackets
|
||||
const prompt3 = await promptService.getResolvedPromptFragment('11', { name: 'John' });
|
||||
expect(prompt3?.text).to.equal('Hello, name}}}}'); // Extra closing bracket, does not match cleanly
|
||||
});
|
||||
|
||||
it('should handle a mixture of two and three brackets correctly', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: '12', template: 'Hi, {{name}}}' }); // (invalid)
|
||||
promptService.addBuiltInPromptFragment({ id: '13', template: 'Hello, {{{name}}' }); // (invalid)
|
||||
promptService.addBuiltInPromptFragment({ id: '14', template: 'Greetings, {{{name}}}}' }); // (invalid)
|
||||
promptService.addBuiltInPromptFragment({ id: '15', template: 'Bye, {{{{name}}}' }); // (invalid)
|
||||
promptService.addBuiltInPromptFragment({ id: '16', template: 'Ciao, {{{{name}}}}' }); // (invalid)
|
||||
promptService.addBuiltInPromptFragment({ id: '17', template: 'Hi, {{name}}! {{{name}}}' }); // Mixed valid patterns
|
||||
|
||||
const prompt12 = await promptService.getResolvedPromptFragment('12', { name: 'John' });
|
||||
expect(prompt12?.text).to.equal('Hi, {{name}}}');
|
||||
|
||||
const prompt13 = await promptService.getResolvedPromptFragment('13', { name: 'John' });
|
||||
expect(prompt13?.text).to.equal('Hello, {{{name}}');
|
||||
|
||||
const prompt14 = await promptService.getResolvedPromptFragment('14', { name: 'John' });
|
||||
expect(prompt14?.text).to.equal('Greetings, {{{name}}}}');
|
||||
|
||||
const prompt15 = await promptService.getResolvedPromptFragment('15', { name: 'John' });
|
||||
expect(prompt15?.text).to.equal('Bye, {{{{name}}}');
|
||||
|
||||
const prompt16 = await promptService.getResolvedPromptFragment('16', { name: 'John' });
|
||||
expect(prompt16?.text).to.equal('Ciao, {{{{name}}}}');
|
||||
|
||||
const prompt17 = await promptService.getResolvedPromptFragment('17', { name: 'John' });
|
||||
expect(prompt17?.text).to.equal('Hi, John! John');
|
||||
});
|
||||
|
||||
it('should strip single-line comments at the start of the template', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-basic', template: '{{!-- Comment --}}Hello, {{name}}!' });
|
||||
const prompt = promptService.getPromptFragment('comment-basic');
|
||||
expect(prompt?.template).to.equal('Hello, {{name}}!');
|
||||
});
|
||||
|
||||
it('should remove line break after first-line comment', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-line-break', template: '{{!-- Comment --}}\nHello, {{name}}!' });
|
||||
const prompt = promptService.getPromptFragment('comment-line-break');
|
||||
expect(prompt?.template).to.equal('Hello, {{name}}!');
|
||||
});
|
||||
|
||||
it('should strip multiline comments at the start of the template', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-multiline', template: '{{!--\nMultiline comment\n--}}\nGoodbye, {{name}}!' });
|
||||
const prompt = promptService.getPromptFragment('comment-multiline');
|
||||
expect(prompt?.template).to.equal('Goodbye, {{name}}!');
|
||||
});
|
||||
|
||||
it('should not strip comments not in the first line', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-second-line', template: 'Hello, {{name}}!\n{{!-- Comment --}}' });
|
||||
const prompt = promptService.getPromptFragment('comment-second-line');
|
||||
expect(prompt?.template).to.equal('Hello, {{name}}!\n{{!-- Comment --}}');
|
||||
});
|
||||
|
||||
it('should treat unclosed comments as regular text', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-unclosed', template: '{{!-- Unclosed comment' });
|
||||
const prompt = promptService.getPromptFragment('comment-unclosed');
|
||||
expect(prompt?.template).to.equal('{{!-- Unclosed comment');
|
||||
});
|
||||
|
||||
it('should treat standalone closing delimiters as regular text', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-standalone', template: '--}} Hello, {{name}}!' });
|
||||
const prompt = promptService.getPromptFragment('comment-standalone');
|
||||
expect(prompt?.template).to.equal('--}} Hello, {{name}}!');
|
||||
});
|
||||
|
||||
it('should handle nested comments and stop at the first closing tag', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'nested-comment', template: '{{!-- {{!-- Nested comment --}} --}}text' });
|
||||
const prompt = promptService.getPromptFragment('nested-comment');
|
||||
expect(prompt?.template).to.equal('--}}text');
|
||||
});
|
||||
|
||||
it('should handle templates with only comments', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-only', template: '{{!-- Only comments --}}' });
|
||||
const prompt = promptService.getPromptFragment('comment-only');
|
||||
expect(prompt?.template).to.equal('');
|
||||
});
|
||||
|
||||
it('should handle mixed delimiters on the same line', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-mixed', template: '{{!-- Unclosed comment --}}' });
|
||||
const prompt = promptService.getPromptFragment('comment-mixed');
|
||||
expect(prompt?.template).to.equal('');
|
||||
});
|
||||
|
||||
it('should resolve variables after stripping single-line comments', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-resolve', template: '{{!-- Comment --}}Hello, {{name}}!' });
|
||||
const prompt = await promptService.getResolvedPromptFragment('comment-resolve', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hello, John!');
|
||||
});
|
||||
|
||||
it('should resolve variables in multiline templates with comments', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-multiline-vars', template: '{{!--\nMultiline comment\n--}}\nHello, {{name}}!' });
|
||||
const prompt = await promptService.getResolvedPromptFragment('comment-multiline-vars', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hello, John!');
|
||||
});
|
||||
|
||||
it('should resolve variables with standalone closing delimiters', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-standalone-vars', template: '--}} Hello, {{name}}!' });
|
||||
const prompt = await promptService.getResolvedPromptFragment('comment-standalone-vars', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('--}} Hello, John!');
|
||||
});
|
||||
|
||||
it('should treat unclosed comments as text and resolve variables', async () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'comment-unclosed-vars', template: '{{!-- Unclosed comment\nHello, {{name}}!' });
|
||||
const prompt = await promptService.getResolvedPromptFragment('comment-unclosed-vars', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('{{!-- Unclosed comment\nHello, John!');
|
||||
});
|
||||
|
||||
it('should handle templates with mixed comments and variables', async () => {
|
||||
promptService.addBuiltInPromptFragment(
|
||||
{ id: 'comment-mixed-vars', template: '{{!-- Comment --}}Hi, {{name}}! {{!-- Another comment --}}' });
|
||||
const prompt = await promptService.getResolvedPromptFragment('comment-mixed-vars', { name: 'John' });
|
||||
expect(prompt?.text).to.equal('Hi, John! {{!-- Another comment --}}');
|
||||
});
|
||||
|
||||
it('should return all variant IDs of a given prompt', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'variant1',
|
||||
template: 'Variant 1',
|
||||
}, 'systemPrompt'
|
||||
);
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'variant2',
|
||||
template: 'Variant 2',
|
||||
}, 'systemPrompt'
|
||||
);
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'variant3',
|
||||
template: 'Variant 3',
|
||||
}, 'systemPrompt'
|
||||
);
|
||||
|
||||
const variantIds = promptService.getVariantIds('systemPrompt');
|
||||
expect(variantIds).to.deep.equal(['variant1', 'variant2', 'variant3']);
|
||||
});
|
||||
|
||||
it('should return an empty array if no variants exist for a given prompt', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'main', template: 'Main template' });
|
||||
|
||||
const variantIds = promptService.getVariantIds('main');
|
||||
expect(variantIds).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should return an empty array if the main prompt ID does not exist', () => {
|
||||
const variantIds = promptService.getVariantIds('nonExistent');
|
||||
expect(variantIds).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should not influence prompts without variants when other prompts have variants', () => {
|
||||
promptService.addBuiltInPromptFragment({ id: 'variant1', template: 'Variant 1' }, 'systemPromptWithVariants', true);
|
||||
promptService.addBuiltInPromptFragment({ id: 'promptFragmentWithoutVariants', template: 'template without variants' });
|
||||
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'variant2',
|
||||
template: 'Variant 2',
|
||||
}, 'systemPromptWithVariants'
|
||||
);
|
||||
|
||||
const systemPromptWithVariants = promptService.getVariantIds('systemPromptWithVariants');
|
||||
const promptFragmentWithoutVariants = promptService.getVariantIds('promptFragmentWithoutVariants');
|
||||
|
||||
expect(systemPromptWithVariants).to.deep.equal(['variant1', 'variant2']);
|
||||
expect(promptFragmentWithoutVariants).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should resolve function references within resolved variable replacements', async () => {
|
||||
// Mock the tool invocation registry
|
||||
const toolInvocationRegistry = {
|
||||
getFunction: sinon.stub()
|
||||
};
|
||||
|
||||
// Create a test tool request that will be returned by the registry
|
||||
const testFunction: ToolRequest = {
|
||||
id: 'testFunction',
|
||||
name: 'Test Function',
|
||||
description: 'A test function',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string',
|
||||
description: 'Test parameter'
|
||||
}
|
||||
}
|
||||
},
|
||||
providerName: 'test-provider',
|
||||
handler: sinon.stub()
|
||||
};
|
||||
toolInvocationRegistry.getFunction.withArgs('testFunction').returns(testFunction);
|
||||
|
||||
// Create a container with our mocked registry
|
||||
const container = new Container();
|
||||
container.bind<PromptService>(PromptService).to(PromptServiceImpl).inSingletonScope();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
container.bind<ToolInvocationRegistry>(ToolInvocationRegistry).toConstantValue(toolInvocationRegistry as any);
|
||||
|
||||
// Set up a variable service that returns a fragment with a function reference
|
||||
const variableService = new DefaultAIVariableService({ getContributions: () => [] }, sinon.createStubInstance(Logger));
|
||||
const fragmentVariable = { id: 'test', name: 'fragment', description: 'Test fragment with function' };
|
||||
variableService.registerResolver(fragmentVariable, {
|
||||
canResolve: () => 100,
|
||||
resolve: async () => ({
|
||||
variable: fragmentVariable,
|
||||
value: 'This fragment contains a function reference: ~{testFunction}'
|
||||
})
|
||||
});
|
||||
container.bind<AIVariableService>(AIVariableService).toConstantValue(variableService);
|
||||
container.bind<ILogger>(ILogger).toConstantValue(new MockLogger);
|
||||
|
||||
const testPromptService = container.get<PromptService>(PromptService);
|
||||
testPromptService.addBuiltInPromptFragment({ id: 'testPrompt', template: 'Template with fragment: {{fragment}}' });
|
||||
|
||||
// Get the resolved prompt
|
||||
const resolvedPrompt = await testPromptService.getResolvedPromptFragment('testPrompt');
|
||||
|
||||
// Verify that the function was resolved
|
||||
expect(resolvedPrompt).to.not.be.undefined;
|
||||
expect(resolvedPrompt?.text).to.include('This fragment contains a function reference:');
|
||||
expect(resolvedPrompt?.text).to.not.include('~{testFunction}');
|
||||
|
||||
// Verify that the function description was added to functionDescriptions
|
||||
expect(resolvedPrompt?.functionDescriptions?.size).to.equal(1);
|
||||
expect(resolvedPrompt?.functionDescriptions?.get('testFunction')).to.deep.equal(testFunction);
|
||||
|
||||
// Verify that the tool invocation registry was called
|
||||
expect(toolInvocationRegistry.getFunction.calledWith('testFunction')).to.be.true;
|
||||
});
|
||||
|
||||
// ===== Command Tests =====
|
||||
|
||||
describe('Command Management', () => {
|
||||
it('getCommands() returns only fragments with isCommand=true', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd1',
|
||||
template: 'Command 1',
|
||||
isCommand: true,
|
||||
commandName: 'cmd1'
|
||||
});
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'normal',
|
||||
template: 'Normal prompt'
|
||||
});
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd2',
|
||||
template: 'Command 2',
|
||||
isCommand: true,
|
||||
commandName: 'cmd2'
|
||||
});
|
||||
|
||||
const commands = promptService.getCommands();
|
||||
expect(commands.length).to.equal(2);
|
||||
expect(commands.map(c => c.id)).to.include('cmd1');
|
||||
expect(commands.map(c => c.id)).to.include('cmd2');
|
||||
expect(commands.map(c => c.id)).to.not.include('normal');
|
||||
});
|
||||
|
||||
it('getCommands(agentId) filters by commandAgents array', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd-universal',
|
||||
template: 'Universal command',
|
||||
isCommand: true,
|
||||
commandName: 'universal',
|
||||
commandAgents: ['Universal']
|
||||
});
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd-specific',
|
||||
template: 'Specific command',
|
||||
isCommand: true,
|
||||
commandName: 'specific',
|
||||
commandAgents: ['SpecificAgent']
|
||||
});
|
||||
|
||||
const universalCommands = promptService.getCommands('Universal');
|
||||
expect(universalCommands.length).to.equal(1);
|
||||
expect(universalCommands[0].id).to.equal('cmd-universal');
|
||||
|
||||
const specificCommands = promptService.getCommands('SpecificAgent');
|
||||
expect(specificCommands.length).to.equal(1);
|
||||
expect(specificCommands[0].id).to.equal('cmd-specific');
|
||||
});
|
||||
|
||||
it('getCommands(agentId) includes commands without commandAgents', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd-all',
|
||||
template: 'Available for all',
|
||||
isCommand: true,
|
||||
commandName: 'all'
|
||||
// No commandAgents means available for all
|
||||
});
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'cmd-specific',
|
||||
template: 'Specific command',
|
||||
isCommand: true,
|
||||
commandName: 'specific',
|
||||
commandAgents: ['Universal']
|
||||
});
|
||||
|
||||
const commands = promptService.getCommands('SomeOtherAgent');
|
||||
expect(commands.length).to.equal(1);
|
||||
expect(commands[0].id).to.equal('cmd-all');
|
||||
});
|
||||
|
||||
it('getCommands() returns empty array when no commands registered', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'normal1',
|
||||
template: 'Normal prompt 1'
|
||||
});
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'normal2',
|
||||
template: 'Normal prompt 2'
|
||||
});
|
||||
|
||||
const commands = promptService.getCommands();
|
||||
expect(commands.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('command metadata preserved through registration', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Test command',
|
||||
isCommand: true,
|
||||
commandName: 'test',
|
||||
commandDescription: 'A test command',
|
||||
commandArgumentHint: '<arg>',
|
||||
commandAgents: ['Agent1', 'Agent2']
|
||||
});
|
||||
|
||||
const commands = promptService.getCommands();
|
||||
expect(commands.length).to.equal(1);
|
||||
const cmd = commands[0];
|
||||
expect(cmd.isCommand).to.be.true;
|
||||
expect(cmd.commandName).to.equal('test');
|
||||
expect(cmd.commandDescription).to.equal('A test command');
|
||||
expect(cmd.commandArgumentHint).to.equal('<arg>');
|
||||
expect(cmd.commandAgents).to.deep.equal(['Agent1', 'Agent2']);
|
||||
});
|
||||
|
||||
it('getFragmentByCommandName finds fragment by command name', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'sample-debug',
|
||||
template: 'Help debug: $ARGUMENTS',
|
||||
isCommand: true,
|
||||
commandName: 'debug',
|
||||
commandDescription: 'Debug an issue',
|
||||
commandArgumentHint: '<problem>'
|
||||
});
|
||||
|
||||
// Should find by command name
|
||||
const fragment = promptService.getPromptFragmentByCommandName('debug');
|
||||
expect(fragment).to.not.be.undefined;
|
||||
expect(fragment?.id).to.equal('sample-debug');
|
||||
expect(fragment?.commandName).to.equal('debug');
|
||||
expect(fragment?.template).to.equal('Help debug: $ARGUMENTS');
|
||||
});
|
||||
|
||||
it('getFragmentByCommandName returns undefined for non-command fragments', () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'normal-fragment',
|
||||
template: 'Not a command'
|
||||
});
|
||||
|
||||
const fragment = promptService.getPromptFragmentByCommandName('normal-fragment');
|
||||
expect(fragment).to.be.undefined;
|
||||
});
|
||||
|
||||
it('getFragmentByCommandName returns undefined for non-existent command', () => {
|
||||
const fragment = promptService.getPromptFragmentByCommandName('non-existent');
|
||||
expect(fragment).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
1158
packages/ai-core/src/common/prompt-service.ts
Normal file
1158
packages/ai-core/src/common/prompt-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
23
packages/ai-core/src/common/prompt-text.ts
Normal file
23
packages/ai-core/src/common/prompt-text.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export namespace PromptText {
|
||||
export const AGENT_CHAR = '@';
|
||||
export const VARIABLE_CHAR = '#';
|
||||
export const FUNCTION_CHAR = '~';
|
||||
export const VARIABLE_SEPARATOR_CHAR = ':';
|
||||
export const COMMAND_CHAR = '/';
|
||||
}
|
||||
236
packages/ai-core/src/common/prompt-variable-contribution.spec.ts
Normal file
236
packages/ai-core/src/common/prompt-variable-contribution.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
|
||||
let disableJSDOM = enableJSDOM();
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Container } from 'inversify';
|
||||
import { CommandService, ILogger, Logger } from '@theia/core';
|
||||
import { PromptVariableContribution, PROMPT_VARIABLE } from './prompt-variable-contribution';
|
||||
import { PromptService, PromptServiceImpl } from './prompt-service';
|
||||
import { DefaultAIVariableService, AIVariableService } from './variable-service';
|
||||
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('PromptVariableContribution', () => {
|
||||
before(() => disableJSDOM = enableJSDOM());
|
||||
after(() => disableJSDOM());
|
||||
let contribution: PromptVariableContribution;
|
||||
let promptService: PromptService;
|
||||
let container: Container;
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
|
||||
// Set up PromptService
|
||||
container.bind<PromptService>(PromptService).to(PromptServiceImpl).inSingletonScope();
|
||||
const logger = sinon.createStubInstance(Logger);
|
||||
const variableService = new DefaultAIVariableService({ getContributions: () => [] }, logger);
|
||||
container.bind<AIVariableService>(AIVariableService).toConstantValue(variableService);
|
||||
container.bind<ILogger>(ILogger).toConstantValue(new MockLogger);
|
||||
|
||||
// Set up CommandService stub (needed for PromptVariableContribution but not used in these tests)
|
||||
const commandService = sinon.createStubInstance(Logger); // Using Logger as a simple mock
|
||||
container.bind<CommandService>(CommandService).toConstantValue(commandService as unknown as CommandService);
|
||||
|
||||
// Bind PromptVariableContribution with proper DI
|
||||
container.bind<PromptVariableContribution>(PromptVariableContribution).toSelf().inSingletonScope();
|
||||
|
||||
// Get instances
|
||||
promptService = container.get<PromptService>(PromptService);
|
||||
contribution = container.get<PromptVariableContribution>(PromptVariableContribution);
|
||||
});
|
||||
|
||||
describe('Command Argument Substitution', () => {
|
||||
it('substitutes $ARGUMENTS with full argument string', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Process: $ARGUMENTS',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|arg1 arg2 arg3' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Process: arg1 arg2 arg3');
|
||||
});
|
||||
|
||||
it('substitutes $0 with command name', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Command $0 was called',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|args' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Command test-cmd was called');
|
||||
});
|
||||
|
||||
it('substitutes $1, $2, ... with individual arguments', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'compare-cmd',
|
||||
template: 'Compare $1 with $2',
|
||||
isCommand: true,
|
||||
commandName: 'compare'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'compare-cmd|item1 item2' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Compare item1 with item2');
|
||||
});
|
||||
|
||||
it('handles quoted arguments in $1, $2', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'First: $1, Second: $2',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|"arg with spaces" other' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('First: arg with spaces, Second: other');
|
||||
});
|
||||
|
||||
it('handles escaped quotes in arguments', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Arg: $1',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|"value with \\"quote\\""' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Arg: value with "quote"');
|
||||
});
|
||||
|
||||
it('handles 10+ arguments correctly', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Args: $1 $10 $11',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|a b c d e f g h i j k' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Args: a j k');
|
||||
});
|
||||
|
||||
it('handles command without arguments', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'hello-cmd',
|
||||
template: 'Hello, world!',
|
||||
isCommand: true,
|
||||
commandName: 'hello'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'hello-cmd' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Hello, world!');
|
||||
});
|
||||
|
||||
it('handles non-command prompts without substitution', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'normal-prompt',
|
||||
template: 'This has $1 and $ARGUMENTS but is not a command'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'normal-prompt' },
|
||||
{}
|
||||
);
|
||||
|
||||
// No substitution should occur for non-commands
|
||||
expect(result?.value).to.equal('This has $1 and $ARGUMENTS but is not a command');
|
||||
});
|
||||
|
||||
it('handles missing argument placeholders gracefully', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Args: $1 $2 $3',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|only-one' },
|
||||
{}
|
||||
);
|
||||
|
||||
// Missing arguments should remain as placeholders
|
||||
expect(result?.value).to.equal('Args: only-one $2 $3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Resolution', () => {
|
||||
it('resolves command fragments correctly', async () => {
|
||||
promptService.addBuiltInPromptFragment({
|
||||
id: 'test-cmd',
|
||||
template: 'Do something with $ARGUMENTS',
|
||||
isCommand: true,
|
||||
commandName: 'test'
|
||||
});
|
||||
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'test-cmd|input' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('Do something with input');
|
||||
expect(result?.variable).to.deep.equal(PROMPT_VARIABLE);
|
||||
});
|
||||
|
||||
it('returns empty string for non-existent prompts', async () => {
|
||||
const result = await contribution.resolve(
|
||||
{ variable: PROMPT_VARIABLE, arg: 'non-existent|args' },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result?.value).to.equal('');
|
||||
});
|
||||
});
|
||||
});
|
||||
246
packages/ai-core/src/common/prompt-variable-contribution.ts
Normal file
246
packages/ai-core/src/common/prompt-variable-contribution.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { CommandService, ILogger, nls } from '@theia/core';
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import {
|
||||
AIVariable,
|
||||
AIVariableContribution,
|
||||
AIVariableService,
|
||||
AIVariableResolutionRequest,
|
||||
AIVariableContext,
|
||||
ResolvedAIVariable,
|
||||
AIVariableResolverWithVariableDependencies,
|
||||
AIVariableArg
|
||||
} from './variable-service';
|
||||
import { isCustomizedPromptFragment, PromptService } from './prompt-service';
|
||||
import { PromptText } from './prompt-text';
|
||||
|
||||
export const PROMPT_VARIABLE: AIVariable = {
|
||||
id: 'prompt-provider',
|
||||
description: nls.localize('theia/ai/core/promptVariable/description', 'Resolves prompt templates via the prompt service'),
|
||||
name: 'prompt',
|
||||
args: [
|
||||
{ name: 'id', description: nls.localize('theia/ai/core/promptVariable/argDescription', 'The prompt template id to resolve') }
|
||||
]
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class PromptVariableContribution implements AIVariableContribution, AIVariableResolverWithVariableDependencies {
|
||||
|
||||
@inject(CommandService)
|
||||
protected readonly commandService: CommandService;
|
||||
|
||||
@inject(PromptService)
|
||||
protected readonly promptService: PromptService;
|
||||
|
||||
@inject(ILogger)
|
||||
protected logger: ILogger;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(PROMPT_VARIABLE, this);
|
||||
service.registerArgumentPicker(PROMPT_VARIABLE, this.triggerArgumentPicker.bind(this));
|
||||
service.registerArgumentCompletionProvider(PROMPT_VARIABLE, this.provideArgumentCompletionItems.bind(this));
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): number {
|
||||
if (request.variable.name === PROMPT_VARIABLE.name) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
request: AIVariableResolutionRequest,
|
||||
context: AIVariableContext,
|
||||
resolveDependency?: (variable: AIVariableArg) => Promise<ResolvedAIVariable | undefined>
|
||||
): Promise<ResolvedAIVariable | undefined> {
|
||||
if (request.variable.name === PROMPT_VARIABLE.name) {
|
||||
const arg = request.arg?.trim();
|
||||
if (arg) {
|
||||
// Check if this is a command-style reference (contains | separator)
|
||||
const pipeIndex = arg.indexOf('|');
|
||||
const promptIdOrCommandName = pipeIndex >= 0 ? arg.substring(0, pipeIndex) : arg;
|
||||
const commandArgs = pipeIndex >= 0 ? arg.substring(pipeIndex + 1) : '';
|
||||
|
||||
// Determine the actual fragment ID
|
||||
// If this is a command invocation (has args), try to find by command name first
|
||||
let fragment = commandArgs
|
||||
? this.promptService.getPromptFragmentByCommandName(promptIdOrCommandName)
|
||||
: undefined;
|
||||
|
||||
// Fall back to looking up by fragment ID if not found by command name
|
||||
if (!fragment) {
|
||||
fragment = this.promptService.getRawPromptFragment(promptIdOrCommandName);
|
||||
}
|
||||
|
||||
// If we still don't have a fragment, we can't resolve
|
||||
if (!fragment) {
|
||||
this.logger.debug(`Could not find prompt fragment or command '${promptIdOrCommandName}'`);
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: '',
|
||||
allResolvedDependencies: []
|
||||
};
|
||||
}
|
||||
|
||||
const fragmentId = fragment.id;
|
||||
|
||||
// Resolve the prompt fragment normally (this handles {{variables}} and ~{functions})
|
||||
const resolvedPrompt = await this.promptService.getResolvedPromptFragmentWithoutFunctions(
|
||||
fragmentId,
|
||||
undefined,
|
||||
context,
|
||||
resolveDependency
|
||||
);
|
||||
|
||||
if (resolvedPrompt) {
|
||||
// If command args were provided, substitute them in the resolved text
|
||||
// This happens AFTER variable/function resolution, so $ARGUMENTS can be part of the template
|
||||
// alongside {{variables}} which get resolved first
|
||||
const isCommand = fragment?.isCommand === true;
|
||||
const finalText = isCommand && commandArgs
|
||||
? this.substituteCommandArguments(resolvedPrompt.text, promptIdOrCommandName, commandArgs)
|
||||
: resolvedPrompt.text;
|
||||
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: finalText,
|
||||
allResolvedDependencies: resolvedPrompt.variables
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Could not resolve prompt variable '${request.variable.name}' with arg '${request.arg}'. Returning empty string.`);
|
||||
return {
|
||||
variable: request.variable,
|
||||
value: '',
|
||||
allResolvedDependencies: []
|
||||
};
|
||||
}
|
||||
|
||||
private substituteCommandArguments(template: string, commandName: string, commandArgs: string): string {
|
||||
// Parse arguments (respecting quotes)
|
||||
const args = this.parseCommandArguments(commandArgs);
|
||||
|
||||
// Substitute $ARGUMENTS with full arg string
|
||||
let result = template.replace(/\$ARGUMENTS/g, commandArgs);
|
||||
|
||||
// Substitute $0 with command name
|
||||
result = result.replace(/\$0/g, commandName);
|
||||
|
||||
// Substitute numbered arguments in reverse order to avoid collision
|
||||
// (e.g., $10 before $1 to prevent $1 from matching the "1" in "$10")
|
||||
for (let i = args.length; i > 0; i--) {
|
||||
const regex = new RegExp(`\\$${i}\\b`, 'g');
|
||||
result = result.replace(regex, args[i - 1]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseCommandArguments(commandArgs: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
|
||||
for (let i = 0; i < commandArgs.length; i++) {
|
||||
const char = commandArgs[i];
|
||||
|
||||
// Handle escape sequences within quotes
|
||||
if (char === '\\' && i + 1 < commandArgs.length && inQuotes) {
|
||||
const nextChar = commandArgs[i + 1];
|
||||
if (nextChar === '"' || nextChar === "'" || nextChar === '\\') {
|
||||
current += nextChar;
|
||||
i++; // Skip the next character
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ((char === '"' || char === "'") && !inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === quoteChar && inQuotes) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
} else if (char === ' ' && !inQuotes) {
|
||||
if (current.trim()) {
|
||||
args.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
args.push(current.trim());
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
protected async triggerArgumentPicker(): Promise<string | undefined> {
|
||||
// Trigger the suggestion command to show argument completions
|
||||
this.commandService.executeCommand('editor.action.triggerSuggest');
|
||||
// Return undefined because we don't actually pick the argument here.
|
||||
// The argument is selected and inserted by the monaco editor's completion mechanism.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async provideArgumentCompletionItems(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position
|
||||
): Promise<monaco.languages.CompletionItem[] | undefined> {
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
|
||||
// Only provide completions once the variable argument separator is typed
|
||||
const triggerCharIndex = lineContent.lastIndexOf(PromptText.VARIABLE_SEPARATOR_CHAR, position.column - 1);
|
||||
if (triggerCharIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if the text immediately before the trigger is the prompt variable, i.e #prompt
|
||||
const requiredVariable = `${PromptText.VARIABLE_CHAR}${PROMPT_VARIABLE.name}`;
|
||||
if (triggerCharIndex < requiredVariable.length ||
|
||||
lineContent.substring(triggerCharIndex - requiredVariable.length, triggerCharIndex) !== requiredVariable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column);
|
||||
|
||||
const activePrompts = this.promptService.getActivePromptFragments();
|
||||
let builtinPromptCompletions: monaco.languages.CompletionItem[] | undefined = undefined;
|
||||
|
||||
if (activePrompts.length > 0) {
|
||||
builtinPromptCompletions = [];
|
||||
activePrompts.forEach(prompt => (builtinPromptCompletions!.push(
|
||||
{
|
||||
label: prompt.id,
|
||||
kind: isCustomizedPromptFragment(prompt) ? monaco.languages.CompletionItemKind.Enum : monaco.languages.CompletionItemKind.Variable,
|
||||
insertText: prompt.id,
|
||||
range,
|
||||
detail: isCustomizedPromptFragment(prompt) ?
|
||||
nls.localize('theia/ai/core/promptVariable/completions/detail/custom', 'Customized prompt fragment') :
|
||||
nls.localize('theia/ai/core/promptVariable/completions/detail/builtin', 'Built-in prompt fragment'),
|
||||
sortText: `${prompt.id}`
|
||||
})));
|
||||
}
|
||||
|
||||
return builtinPromptCompletions;
|
||||
}
|
||||
}
|
||||
45
packages/ai-core/src/common/protocol.ts
Normal file
45
packages/ai-core/src/common/protocol.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Event } from '@theia/core';
|
||||
import { LanguageModelMetaData } from './language-model';
|
||||
import { TokenUsage } from './token-usage-service';
|
||||
|
||||
export const LanguageModelRegistryClient = Symbol('LanguageModelRegistryClient');
|
||||
export interface LanguageModelRegistryClient {
|
||||
languageModelAdded(metadata: LanguageModelMetaData): void;
|
||||
languageModelRemoved(id: string): void;
|
||||
/**
|
||||
* Notify the client that a language model was updated.
|
||||
*/
|
||||
onLanguageModelUpdated(id: string): void;
|
||||
}
|
||||
|
||||
export const TOKEN_USAGE_SERVICE_PATH = '/services/token-usage';
|
||||
|
||||
export const TokenUsageServiceClient = Symbol('TokenUsageServiceClient');
|
||||
|
||||
export interface TokenUsageServiceClient {
|
||||
/**
|
||||
* Notify the client about new token usage
|
||||
*/
|
||||
notifyTokenUsage(usage: TokenUsage): void;
|
||||
|
||||
/**
|
||||
* An event that is fired when token usage data is updated.
|
||||
*/
|
||||
readonly onTokenUsageUpdated: Event<TokenUsage>;
|
||||
}
|
||||
44
packages/ai-core/src/common/settings-service.ts
Normal file
44
packages/ai-core/src/common/settings-service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { Event } from '@theia/core';
|
||||
import { LanguageModelRequirement } from './language-model';
|
||||
import { NotificationType } from './notification-types';
|
||||
|
||||
export const AISettingsService = Symbol('AISettingsService');
|
||||
/**
|
||||
* Service to store and retrieve settings on a per-agent basis.
|
||||
*/
|
||||
export interface AISettingsService {
|
||||
updateAgentSettings(agent: string, agentSettings: Partial<AgentSettings>): Promise<void>;
|
||||
getAgentSettings(agent: string): Promise<AgentSettings | undefined>;
|
||||
getSettings(): Promise<AISettings>;
|
||||
onDidChange: Event<void>;
|
||||
}
|
||||
export type AISettings = Record<string, AgentSettings>;
|
||||
export interface AgentSettings {
|
||||
languageModelRequirements?: LanguageModelRequirement[];
|
||||
enable?: boolean;
|
||||
/**
|
||||
* A mapping of main template IDs to their selected variant IDs.
|
||||
* If a main template is not present in this mapping, it means the main template is used.
|
||||
*/
|
||||
selectedVariants?: Record<string, string>;
|
||||
/**
|
||||
* Configuration for completion notifications when the agent finishes a task.
|
||||
* If undefined, defaults to 'off'.
|
||||
*/
|
||||
completionNotification?: NotificationType;
|
||||
}
|
||||
222
packages/ai-core/src/common/skill.spec.ts
Normal file
222
packages/ai-core/src/common/skill.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
SKILL_FILE_NAME,
|
||||
SkillDescription,
|
||||
isValidSkillName,
|
||||
validateSkillDescription
|
||||
} from './skill';
|
||||
|
||||
describe('Skill Types', () => {
|
||||
|
||||
describe('SKILL_FILE_NAME', () => {
|
||||
it('should be SKILL.md', () => {
|
||||
expect(SKILL_FILE_NAME).to.equal('SKILL.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSkillName', () => {
|
||||
it('should accept simple lowercase names', () => {
|
||||
expect(isValidSkillName('skill')).to.be.true;
|
||||
});
|
||||
|
||||
it('should accept kebab-case names', () => {
|
||||
expect(isValidSkillName('my-skill')).to.be.true;
|
||||
});
|
||||
|
||||
it('should accept names with digits', () => {
|
||||
expect(isValidSkillName('skill1')).to.be.true;
|
||||
expect(isValidSkillName('my-skill-2')).to.be.true;
|
||||
expect(isValidSkillName('1skill')).to.be.true;
|
||||
});
|
||||
|
||||
it('should accept multi-part kebab-case names', () => {
|
||||
expect(isValidSkillName('my-awesome-skill')).to.be.true;
|
||||
});
|
||||
|
||||
it('should reject uppercase letters', () => {
|
||||
expect(isValidSkillName('MySkill')).to.be.false;
|
||||
expect(isValidSkillName('SKILL')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject leading hyphens', () => {
|
||||
expect(isValidSkillName('-skill')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject trailing hyphens', () => {
|
||||
expect(isValidSkillName('skill-')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject consecutive hyphens', () => {
|
||||
expect(isValidSkillName('my--skill')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject spaces', () => {
|
||||
expect(isValidSkillName('my skill')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject underscores', () => {
|
||||
expect(isValidSkillName('my_skill')).to.be.false;
|
||||
});
|
||||
|
||||
it('should reject empty strings', () => {
|
||||
expect(isValidSkillName('')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillDescription.is', () => {
|
||||
it('should return true for valid SkillDescription', () => {
|
||||
const valid: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'A test skill'
|
||||
};
|
||||
expect(SkillDescription.is(valid)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true for SkillDescription with optional fields', () => {
|
||||
const valid: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
compatibility: '>=1.0.0',
|
||||
metadata: { author: 'Test' },
|
||||
allowedTools: ['tool1', 'tool2']
|
||||
};
|
||||
expect(SkillDescription.is(valid)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(SkillDescription.is(undefined)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
expect(SkillDescription.is(null)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for non-objects', () => {
|
||||
expect(SkillDescription.is('string')).to.be.false;
|
||||
expect(SkillDescription.is(123)).to.be.false;
|
||||
expect(SkillDescription.is(true)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false when name is missing', () => {
|
||||
expect(SkillDescription.is({ description: 'A skill' })).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false when description is missing', () => {
|
||||
expect(SkillDescription.is({ name: 'my-skill' })).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false when name is not a string', () => {
|
||||
expect(SkillDescription.is({ name: 123, description: 'A skill' })).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false when description is not a string', () => {
|
||||
expect(SkillDescription.is({ name: 'my-skill', description: 123 })).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('SkillDescription.equals', () => {
|
||||
it('should return true for equal names', () => {
|
||||
const a: SkillDescription = { name: 'skill', description: 'Description A' };
|
||||
const b: SkillDescription = { name: 'skill', description: 'Description B' };
|
||||
expect(SkillDescription.equals(a, b)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for different names', () => {
|
||||
const a: SkillDescription = { name: 'skill-a', description: 'Same description' };
|
||||
const b: SkillDescription = { name: 'skill-b', description: 'Same description' };
|
||||
expect(SkillDescription.equals(a, b)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSkillDescription', () => {
|
||||
it('should return empty array for valid skill description', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'A valid skill description'
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'my-skill');
|
||||
expect(errors).to.be.empty;
|
||||
});
|
||||
|
||||
it('should return error when name does not match directory name', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'A skill'
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'other-directory');
|
||||
expect(errors).to.include("Skill name 'my-skill' must match directory name 'other-directory'. Skipping skill.");
|
||||
});
|
||||
|
||||
it('should return error for invalid name format', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'My-Skill',
|
||||
description: 'A skill'
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'My-Skill');
|
||||
expect(errors.some(e => e.includes('must be lowercase kebab-case'))).to.be.true;
|
||||
});
|
||||
|
||||
it('should return error when description exceeds maximum length', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'x'.repeat(1025)
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'my-skill');
|
||||
expect(errors.some(e => e.includes('exceeds maximum length'))).to.be.true;
|
||||
});
|
||||
|
||||
it('should return error when name is not a string', () => {
|
||||
const description = {
|
||||
name: 123,
|
||||
description: 'A skill'
|
||||
} as unknown as SkillDescription;
|
||||
const errors = validateSkillDescription(description, 'my-skill');
|
||||
expect(errors).to.include('Skill name must be a string');
|
||||
});
|
||||
|
||||
it('should return error when description is not a string', () => {
|
||||
const description = {
|
||||
name: 'my-skill',
|
||||
description: 123
|
||||
} as unknown as SkillDescription;
|
||||
const errors = validateSkillDescription(description, 'my-skill');
|
||||
expect(errors).to.include('Skill description must be a string');
|
||||
});
|
||||
|
||||
it('should return multiple errors when multiple validations fail', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'Invalid_Name',
|
||||
description: 'x'.repeat(1025)
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'wrong-dir');
|
||||
expect(errors.length).to.be.greaterThan(1);
|
||||
});
|
||||
|
||||
it('should accept description at exactly maximum length', () => {
|
||||
const description: SkillDescription = {
|
||||
name: 'my-skill',
|
||||
description: 'x'.repeat(1024)
|
||||
};
|
||||
const errors = validateSkillDescription(description, 'my-skill');
|
||||
expect(errors).to.be.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
190
packages/ai-core/src/common/skill.ts
Normal file
190
packages/ai-core/src/common/skill.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
/**
|
||||
* The standard filename for skill definition files.
|
||||
*/
|
||||
export const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
/**
|
||||
* Regular expression for valid skill names.
|
||||
* Must be lowercase kebab-case with digits allowed.
|
||||
* Examples: 'my-skill', 'skill1', 'my-skill-2'
|
||||
*/
|
||||
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Maximum allowed length for skill descriptions.
|
||||
*/
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
/**
|
||||
* Represents the YAML frontmatter metadata from a SKILL.md file.
|
||||
*/
|
||||
export interface SkillDescription {
|
||||
/** Unique identifier, must match directory name, lowercase kebab-case with digits allowed */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of the skill, max 1024 characters */
|
||||
description: string;
|
||||
|
||||
/** Optional SPDX license identifier */
|
||||
license?: string;
|
||||
|
||||
/** Optional version constraint for compatibility */
|
||||
compatibility?: string;
|
||||
|
||||
/** Optional key-value pairs for additional metadata */
|
||||
metadata?: Record<string, string>;
|
||||
|
||||
/** Optional experimental feature: list of allowed tool IDs */
|
||||
allowedTools?: string[];
|
||||
}
|
||||
|
||||
export namespace SkillDescription {
|
||||
/**
|
||||
* Type guard to check if an unknown value is a valid SkillDescription.
|
||||
* Validates that required fields exist and have correct types.
|
||||
*/
|
||||
export function is(entry: unknown): entry is SkillDescription {
|
||||
if (typeof entry !== 'object' || entry === undefined) {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
if (entry === null) {
|
||||
return false;
|
||||
}
|
||||
const obj = entry as Record<string, unknown>;
|
||||
return typeof obj.name === 'string' && typeof obj.description === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two SkillDescription objects for equality based on name.
|
||||
*/
|
||||
export function equals(a: SkillDescription, b: SkillDescription): boolean {
|
||||
return a.name === b.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full skill representation including location.
|
||||
*/
|
||||
export interface Skill extends SkillDescription {
|
||||
/** Absolute file path to the SKILL.md file */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a skill name follows the required format.
|
||||
* Valid names are lowercase kebab-case with digits allowed.
|
||||
* No leading/trailing/consecutive hyphens.
|
||||
*
|
||||
* @param name The skill name to validate
|
||||
* @returns true if the name is valid, false otherwise
|
||||
*/
|
||||
export function isValidSkillName(name: string): boolean {
|
||||
return SKILL_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a SkillDescription against all constraints.
|
||||
*
|
||||
* @param description The skill description to validate
|
||||
* @param directoryName The name of the directory containing the SKILL.md file
|
||||
* @returns Array of validation error messages, empty if valid
|
||||
*/
|
||||
export function validateSkillDescription(description: SkillDescription, directoryName: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof description.name !== 'string') {
|
||||
errors.push('Skill name must be a string');
|
||||
} else {
|
||||
if (description.name !== directoryName) {
|
||||
errors.push(`Skill name '${description.name}' must match directory name '${directoryName}'. Skipping skill.`);
|
||||
}
|
||||
if (!isValidSkillName(description.name)) {
|
||||
errors.push(`Skill name '${description.name}' must be lowercase kebab-case (e.g., 'my-skill', 'skill1')`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof description.description !== 'string') {
|
||||
errors.push('Skill description must be a string');
|
||||
} else if (description.description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
errors.push(`Skill description exceeds maximum length of ${MAX_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a SKILL.md file content, extracting YAML frontmatter metadata and markdown content.
|
||||
* @param content The raw file content
|
||||
* @returns Object with parsed metadata (if valid) and the markdown content
|
||||
*/
|
||||
export function parseSkillFile(content: string): { metadata: SkillDescription | undefined, content: string } {
|
||||
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
||||
const match = content.match(frontMatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { metadata: undefined, content };
|
||||
}
|
||||
|
||||
try {
|
||||
const yamlContent = match[1];
|
||||
const markdownContent = match[2].trim();
|
||||
const parsedYaml = load(yamlContent);
|
||||
|
||||
if (!parsedYaml || typeof parsedYaml !== 'object') {
|
||||
return { metadata: undefined, content };
|
||||
}
|
||||
|
||||
// Validate that required fields are present (name and description)
|
||||
if (!SkillDescription.is(parsedYaml)) {
|
||||
return { metadata: undefined, content };
|
||||
}
|
||||
|
||||
return { metadata: parsedYaml, content: markdownContent };
|
||||
} catch {
|
||||
return { metadata: undefined, content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines skill directories with proper priority ordering.
|
||||
* Workspace directory has highest priority, followed by configured directories, then default.
|
||||
* First directory wins on duplicates.
|
||||
*/
|
||||
export function combineSkillDirectories(
|
||||
workspaceSkillsDir: string | undefined,
|
||||
configuredDirectories: string[],
|
||||
defaultSkillsDir: string | undefined
|
||||
): string[] {
|
||||
const allDirectories: string[] = [];
|
||||
if (workspaceSkillsDir) {
|
||||
allDirectories.push(workspaceSkillsDir);
|
||||
}
|
||||
for (const dir of configuredDirectories) {
|
||||
if (!allDirectories.includes(dir)) {
|
||||
allDirectories.push(dir);
|
||||
}
|
||||
}
|
||||
if (defaultSkillsDir && !allDirectories.includes(defaultSkillsDir)) {
|
||||
allDirectories.push(defaultSkillsDir);
|
||||
}
|
||||
return allDirectories;
|
||||
}
|
||||
71
packages/ai-core/src/common/today-variable-contribution.ts
Normal file
71
packages/ai-core/src/common/today-variable-contribution.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from './variable-service';
|
||||
|
||||
export namespace TodayVariableArgs {
|
||||
export const IN_UNIX_SECONDS = 'inUnixSeconds';
|
||||
export const IN_ISO_8601 = 'inIso8601';
|
||||
}
|
||||
|
||||
export const TODAY_VARIABLE: AIVariable = {
|
||||
id: 'today-provider',
|
||||
description: nls.localize('theia/ai/core/todayVariable/description', 'Does something for today'),
|
||||
name: 'today',
|
||||
args: [
|
||||
{
|
||||
name: 'Format',
|
||||
description: nls.localize('theia/ai/core/todayVariable/format/description', 'The format of the date'),
|
||||
enum: [TodayVariableArgs.IN_ISO_8601, TodayVariableArgs.IN_UNIX_SECONDS],
|
||||
isOptional: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export interface ResolvedTodayVariable extends ResolvedAIVariable {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TodayVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(TODAY_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
if (request.variable.name === TODAY_VARIABLE.name) {
|
||||
return this.resolveTodayVariable(request);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private resolveTodayVariable(request: AIVariableResolutionRequest): ResolvedTodayVariable {
|
||||
const date = new Date();
|
||||
if (request.arg === TodayVariableArgs.IN_ISO_8601) {
|
||||
return { variable: request.variable, value: date.toISOString(), date };
|
||||
}
|
||||
if (request.arg === TodayVariableArgs.IN_UNIX_SECONDS) {
|
||||
return { variable: request.variable, value: Math.round(date.getTime() / 1000).toString(), date };
|
||||
}
|
||||
return { variable: request.variable, value: date.toDateString(), date };
|
||||
}
|
||||
}
|
||||
|
||||
64
packages/ai-core/src/common/token-usage-service.ts
Normal file
64
packages/ai-core/src/common/token-usage-service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { TokenUsageServiceClient } from './protocol';
|
||||
|
||||
export const TokenUsageService = Symbol('TokenUsageService');
|
||||
|
||||
export interface TokenUsage {
|
||||
/** The input token count */
|
||||
inputTokens: number;
|
||||
/** The output token count */
|
||||
outputTokens: number;
|
||||
/** Input tokens written to cache */
|
||||
cachedInputTokens?: number;
|
||||
/** Input tokens read from cache */
|
||||
readCachedInputTokens?: number;
|
||||
/** The model identifier */
|
||||
model: string;
|
||||
/** The timestamp of when the tokens were used */
|
||||
timestamp: Date;
|
||||
/** Request identifier */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface TokenUsageParams {
|
||||
/** The input token count */
|
||||
inputTokens: number;
|
||||
/** The output token count */
|
||||
outputTokens: number;
|
||||
/** Input tokens placed in cache */
|
||||
cachedInputTokens?: number;
|
||||
/** Input tokens read from cache */
|
||||
readCachedInputTokens?: number;
|
||||
/** Request identifier */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface TokenUsageService {
|
||||
/**
|
||||
* Records token usage for a model interaction.
|
||||
*
|
||||
* @param model The identifier of the model that was used
|
||||
* @param params Object containing token usage information
|
||||
* @returns A promise that resolves when the token usage has been recorded
|
||||
*/
|
||||
recordTokenUsage(model: string, params: TokenUsageParams): Promise<void>;
|
||||
|
||||
getTokenUsages(): Promise<TokenUsage[]>;
|
||||
|
||||
setClient(tokenUsageClient: TokenUsageServiceClient): void;
|
||||
}
|
||||
148
packages/ai-core/src/common/tool-invocation-registry.ts
Normal file
148
packages/ai-core/src/common/tool-invocation-registry.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named, postConstruct, interfaces } from '@theia/core/shared/inversify';
|
||||
import { ToolRequest } from './language-model';
|
||||
import { ContributionProvider, Emitter, Event } from '@theia/core';
|
||||
|
||||
export const ToolInvocationRegistry = Symbol('ToolInvocationRegistry');
|
||||
|
||||
/**
|
||||
* Registry for all the function calls available to Agents.
|
||||
*/
|
||||
export interface ToolInvocationRegistry {
|
||||
/**
|
||||
* Registers a tool into the registry.
|
||||
*
|
||||
* @param tool - The `ToolRequest` object representing the tool to be registered.
|
||||
*/
|
||||
registerTool(tool: ToolRequest): void;
|
||||
|
||||
/**
|
||||
* Retrieves a specific `ToolRequest` from the registry.
|
||||
*
|
||||
* @param toolId - The unique identifier of the tool to retrieve.
|
||||
* @returns The `ToolRequest` object corresponding to the provided tool ID,
|
||||
* or `undefined` if the tool is not found in the registry.
|
||||
*/
|
||||
getFunction(toolId: string): ToolRequest | undefined;
|
||||
|
||||
/**
|
||||
* Retrieves multiple `ToolRequest`s from the registry.
|
||||
*
|
||||
* @param toolIds - A list of tool IDs to retrieve.
|
||||
* @returns An array of `ToolRequest` objects for the specified tool IDs.
|
||||
* If a tool ID is not found, it is skipped in the returned array.
|
||||
*/
|
||||
getFunctions(...toolIds: string[]): ToolRequest[];
|
||||
|
||||
/**
|
||||
* Retrieves all `ToolRequest`s currently registered in the registry.
|
||||
*
|
||||
* @returns An array of all `ToolRequest` objects in the registry.
|
||||
*/
|
||||
getAllFunctions(): ToolRequest[];
|
||||
|
||||
/**
|
||||
* Unregisters all tools provided by a specific tool provider.
|
||||
*
|
||||
* @param providerName - The name of the tool provider whose tools should be removed (as specificed in the `ToolRequest`).
|
||||
*/
|
||||
unregisterAllTools(providerName: string): void;
|
||||
|
||||
/**
|
||||
* Event that is fired whenever the registry changes (tool registered or unregistered).
|
||||
*/
|
||||
onDidChange: Event<void>;
|
||||
}
|
||||
|
||||
export const ToolProvider = Symbol('ToolProvider');
|
||||
export interface ToolProvider {
|
||||
getTool(): ToolRequest;
|
||||
}
|
||||
|
||||
/** Binds the identifier to self in singleton scope and then binds `ToolProvider` to that service. */
|
||||
export function bindToolProvider(identifier: interfaces.Newable<ToolProvider>, bind: interfaces.Bind): void {
|
||||
bind(identifier).toSelf().inSingletonScope();
|
||||
bind(ToolProvider).toService(identifier);
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ToolInvocationRegistryImpl implements ToolInvocationRegistry {
|
||||
|
||||
private tools: Map<string, ToolRequest> = new Map<string, ToolRequest>();
|
||||
|
||||
private readonly onDidChangeEmitter = new Emitter<void>();
|
||||
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
|
||||
|
||||
@inject(ContributionProvider)
|
||||
@named(ToolProvider)
|
||||
private providers: ContributionProvider<ToolProvider>;
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.providers.getContributions().forEach(provider => {
|
||||
this.registerTool(provider.getTool());
|
||||
});
|
||||
}
|
||||
|
||||
unregisterAllTools(providerName: string): void {
|
||||
const toolsToRemove: string[] = [];
|
||||
for (const [id, tool] of this.tools.entries()) {
|
||||
if (tool.providerName === providerName) {
|
||||
toolsToRemove.push(id);
|
||||
}
|
||||
}
|
||||
let changed = false;
|
||||
toolsToRemove.forEach(id => {
|
||||
if (this.tools.delete(id)) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
getAllFunctions(): ToolRequest[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
registerTool(tool: ToolRequest): void {
|
||||
if (this.tools.has(tool.id)) {
|
||||
console.warn(`Function with id ${tool.id} is already registered.`);
|
||||
} else {
|
||||
this.tools.set(tool.id, tool);
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
getFunction(toolId: string): ToolRequest | undefined {
|
||||
return this.tools.get(toolId);
|
||||
}
|
||||
|
||||
getFunctions(...toolIds: string[]): ToolRequest[] {
|
||||
const tools: ToolRequest[] = toolIds.map(toolId => {
|
||||
const tool = this.tools.get(toolId);
|
||||
if (tool) {
|
||||
return tool;
|
||||
} else {
|
||||
throw new Error(`Function with id ${toolId} does not exist.`);
|
||||
}
|
||||
});
|
||||
return tools;
|
||||
}
|
||||
}
|
||||
|
||||
289
packages/ai-core/src/common/variable-service.spec.ts
Normal file
289
packages/ai-core/src/common/variable-service.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import { ContributionProvider, Logger } from '@theia/core';
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
DefaultAIVariableService,
|
||||
AIVariable,
|
||||
AIVariableContribution,
|
||||
AIVariableResolverWithVariableDependencies,
|
||||
ResolvedAIVariable,
|
||||
createAIResolveVariableCache,
|
||||
AIVariableArg
|
||||
} from './variable-service';
|
||||
|
||||
describe('DefaultAIVariableService', () => {
|
||||
let variableService: DefaultAIVariableService;
|
||||
let contributionProvider: sinon.SinonStubbedInstance<ContributionProvider<AIVariableContribution>>;
|
||||
let logger: sinon.SinonStubbedInstance<Logger>;
|
||||
|
||||
const varA: AIVariable = {
|
||||
id: 'provider.a',
|
||||
name: 'a',
|
||||
description: 'Variable A'
|
||||
};
|
||||
|
||||
const varB: AIVariable = {
|
||||
id: 'provider.b',
|
||||
name: 'b',
|
||||
description: 'Variable B'
|
||||
};
|
||||
|
||||
const varC: AIVariable = {
|
||||
id: 'provider.c',
|
||||
name: 'c',
|
||||
description: 'Variable C'
|
||||
};
|
||||
|
||||
const varD: AIVariable = {
|
||||
id: 'provider.d',
|
||||
name: 'd',
|
||||
description: 'Variable D'
|
||||
};
|
||||
|
||||
// Create resolvers for our variables
|
||||
const resolverA: AIVariableResolverWithVariableDependencies = {
|
||||
canResolve: sinon.stub().returns(1),
|
||||
resolve: async (_request, _context, resolveDependency?: (variable: AIVariableArg) => Promise<ResolvedAIVariable | undefined>) => {
|
||||
if (resolveDependency) {
|
||||
// Variable A depends on both B and C
|
||||
const dependencyB = await resolveDependency({ variable: varB });
|
||||
const dependencyC = await resolveDependency({ variable: varC });
|
||||
|
||||
return {
|
||||
variable: varA,
|
||||
value: `A resolved with B: ${dependencyB?.value ?? 'undefined'} and C: ${dependencyC?.value ?? 'undefined'}`,
|
||||
allResolvedDependencies: [
|
||||
...(dependencyB ? [dependencyB] : []),
|
||||
...(dependencyC ? [dependencyC] : [])
|
||||
]
|
||||
};
|
||||
}
|
||||
return { variable: varA, value: 'A value' };
|
||||
}
|
||||
};
|
||||
|
||||
const resolverB: AIVariableResolverWithVariableDependencies = {
|
||||
canResolve: sinon.stub().returns(1),
|
||||
resolve: async (_request, _context, resolveDependency?: (variable: AIVariableArg) => Promise<ResolvedAIVariable | undefined>) => {
|
||||
if (resolveDependency) {
|
||||
// Variable B depends on A, creating a cycle
|
||||
const dependencyA = await resolveDependency({ variable: varA });
|
||||
|
||||
return {
|
||||
variable: varB,
|
||||
value: `B resolved with A: ${dependencyA?.value ?? 'undefined (cycle detected)'}`,
|
||||
allResolvedDependencies: dependencyA ? [dependencyA] : []
|
||||
};
|
||||
}
|
||||
return { variable: varB, value: 'B value' };
|
||||
}
|
||||
};
|
||||
|
||||
const resolverC: AIVariableResolverWithVariableDependencies = {
|
||||
canResolve: sinon.stub().returns(1),
|
||||
resolve: async (_request, _context, resolveDependency?: (variable: AIVariableArg) => Promise<ResolvedAIVariable | undefined>) => {
|
||||
if (resolveDependency) {
|
||||
// Variable C depends on D with two different arguments
|
||||
const dependencyD1 = await resolveDependency({ variable: varD, arg: 'arg1' });
|
||||
const dependencyD2 = await resolveDependency({ variable: varD, arg: 'arg2' });
|
||||
|
||||
return {
|
||||
variable: varC,
|
||||
value: `C resolved with D(arg1): ${dependencyD1?.value ?? 'undefined'} and D(arg2): ${dependencyD2?.value ?? 'undefined'}`,
|
||||
allResolvedDependencies: [
|
||||
...(dependencyD1 ? [dependencyD1] : []),
|
||||
...(dependencyD2 ? [dependencyD2] : [])
|
||||
]
|
||||
};
|
||||
}
|
||||
return { variable: varC, value: 'C value' };
|
||||
}
|
||||
};
|
||||
|
||||
const resolverD: AIVariableResolverWithVariableDependencies = {
|
||||
canResolve: sinon.stub().returns(1),
|
||||
resolve: async request => {
|
||||
const arg = request.arg;
|
||||
return {
|
||||
variable: varD,
|
||||
value: arg ? `D value with ${arg}` : 'D value'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create stub for the contribution provider
|
||||
contributionProvider = {
|
||||
getContributions: sinon.stub().returns([])
|
||||
} as sinon.SinonStubbedInstance<ContributionProvider<AIVariableContribution>>;
|
||||
|
||||
// Create stub for logger
|
||||
logger = sinon.createStubInstance(Logger);
|
||||
|
||||
// Create the service under test
|
||||
variableService = new DefaultAIVariableService(
|
||||
contributionProvider,
|
||||
logger
|
||||
);
|
||||
|
||||
// Register the variables and resolvers
|
||||
variableService.registerResolver(varA, resolverA);
|
||||
variableService.registerResolver(varB, resolverB);
|
||||
variableService.registerResolver(varC, resolverC);
|
||||
variableService.registerResolver(varD, resolverD);
|
||||
});
|
||||
|
||||
describe('resolveVariable', () => {
|
||||
it('should handle recursive variable resolution and detect cycles', async () => {
|
||||
// Try to resolve variable A, which has a cycle with B and also depends on C which depends on D
|
||||
const result = await variableService.resolveVariable('a', {});
|
||||
|
||||
// Verify the result
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.deep.equal(varA);
|
||||
|
||||
// The value should contain B's value (with a cycle detection) and C's value
|
||||
expect(result!.value).to.include('B resolved with A: undefined (cycle detected)');
|
||||
expect(result!.value).to.include('C resolved with D(arg1): D value with arg1 and D(arg2): D value with arg2');
|
||||
|
||||
// Verify that we logged a warning about the cycle
|
||||
expect(logger.warn.calledOnce).to.be.true;
|
||||
expect(logger.warn.firstCall.args[0]).to.include('Cycle detected for variable: a');
|
||||
|
||||
// Verify dependencies are tracked
|
||||
expect(result!.allResolvedDependencies).to.have.lengthOf(2);
|
||||
|
||||
// Find the B dependency and verify it doesn't have A in its dependencies (due to cycle detection)
|
||||
const bDependency = result!.allResolvedDependencies!.find(d => d.variable.name === 'b');
|
||||
expect(bDependency).to.not.be.undefined;
|
||||
expect(bDependency!.allResolvedDependencies).to.be.empty;
|
||||
|
||||
// Find the C dependency and its D dependencies
|
||||
const cDependency = result!.allResolvedDependencies!.find(d => d.variable.name === 'c');
|
||||
expect(cDependency).to.not.be.undefined;
|
||||
expect(cDependency!.value).to.equal('C resolved with D(arg1): D value with arg1 and D(arg2): D value with arg2');
|
||||
expect(cDependency!.allResolvedDependencies).to.have.lengthOf(2);
|
||||
|
||||
const dDependency1 = cDependency!.allResolvedDependencies![0];
|
||||
expect(dDependency1.variable.name).to.equal('d');
|
||||
expect(dDependency1.value).to.equal('D value with arg1');
|
||||
|
||||
const dDependency2 = cDependency!.allResolvedDependencies![1];
|
||||
expect(dDependency2.variable.name).to.equal('d');
|
||||
expect(dDependency2.value).to.equal('D value with arg2');
|
||||
});
|
||||
|
||||
it('should handle variables with a simple chain of dependencies', async () => {
|
||||
// Variable C depends on D with two different arguments
|
||||
const result = await variableService.resolveVariable('c', {});
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.deep.equal(varC);
|
||||
expect(result!.value).to.equal('C resolved with D(arg1): D value with arg1 and D(arg2): D value with arg2');
|
||||
|
||||
// Verify dependency chain
|
||||
expect(result!.allResolvedDependencies).to.have.lengthOf(2);
|
||||
|
||||
const dDependency1 = result!.allResolvedDependencies![0];
|
||||
expect(dDependency1.variable.name).to.equal('d');
|
||||
expect(dDependency1.value).to.equal('D value with arg1');
|
||||
|
||||
const dDependency2 = result!.allResolvedDependencies![1];
|
||||
expect(dDependency2.variable.name).to.equal('d');
|
||||
expect(dDependency2.value).to.equal('D value with arg2');
|
||||
});
|
||||
|
||||
it('should handle variables without dependencies', async () => {
|
||||
// D has no dependencies
|
||||
const result = await variableService.resolveVariable('d', {});
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.deep.equal(varD);
|
||||
expect(result!.value).to.equal('D value');
|
||||
expect(result!.allResolvedDependencies).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should handle variables with arguments', async () => {
|
||||
// Test D with an argument
|
||||
const result = await variableService.resolveVariable({ variable: 'd', arg: 'test-arg' }, {}, undefined);
|
||||
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.deep.equal(varD);
|
||||
expect(result!.value).to.equal('D value with test-arg');
|
||||
expect(result!.allResolvedDependencies).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent variables', async () => {
|
||||
const result = await variableService.resolveVariable('nonexistent', {});
|
||||
expect(result).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should properly populate cache when resolving variables with dependencies', async () => {
|
||||
// Create a cache to pass into the resolver
|
||||
const cache = createAIResolveVariableCache();
|
||||
|
||||
// Resolve variable A, which depends on B and C, and C depends on D with two arguments
|
||||
const result = await variableService.resolveVariable('a', {}, cache);
|
||||
|
||||
// Verify that the result is correct
|
||||
expect(result).to.not.be.undefined;
|
||||
expect(result!.variable).to.deep.equal(varA);
|
||||
|
||||
// Verify that the cache has entries for all variables
|
||||
expect(cache.size).to.equal(5); // A, B, C, D(arg1), and D(arg2)
|
||||
|
||||
// Verify that all variables have entries in the cache
|
||||
expect(cache.has('a:')).to.be.true; // 'a:' key format is variableName + separator + arg (empty in this case)
|
||||
expect(cache.has('b:')).to.be.true;
|
||||
expect(cache.has('c:')).to.be.true;
|
||||
expect(cache.has('d:arg1')).to.be.true;
|
||||
expect(cache.has('d:arg2')).to.be.true;
|
||||
|
||||
// Verify that all promises in the cache are resolved
|
||||
for (const entry of cache.values()) {
|
||||
const resolvedVar = await entry.promise;
|
||||
expect(resolvedVar).to.not.be.undefined;
|
||||
expect(entry.inProgress).to.be.false;
|
||||
}
|
||||
|
||||
// Check specific variable results from cache
|
||||
const aEntry = cache.get('a:');
|
||||
const aResult = await aEntry!.promise;
|
||||
expect(aResult!.variable.name).to.equal('a');
|
||||
|
||||
const bEntry = cache.get('b:');
|
||||
const bResult = await bEntry!.promise;
|
||||
expect(bResult!.variable.name).to.equal('b');
|
||||
|
||||
const cEntry = cache.get('c:');
|
||||
const cResult = await cEntry!.promise;
|
||||
expect(cResult!.variable.name).to.equal('c');
|
||||
|
||||
const dEntry1 = cache.get('d:arg1');
|
||||
const dResult1 = await dEntry1!.promise;
|
||||
expect(dResult1!.variable.name).to.equal('d');
|
||||
expect(dResult1!.value).to.equal('D value with arg1');
|
||||
|
||||
const dEntry2 = cache.get('d:arg2');
|
||||
const dResult2 = await dEntry2!.promise;
|
||||
expect(dResult2!.variable.name).to.equal('d');
|
||||
expect(dResult2!.value).to.equal('D value with arg2');
|
||||
});
|
||||
});
|
||||
});
|
||||
427
packages/ai-core/src/common/variable-service.ts
Normal file
427
packages/ai-core/src/common/variable-service.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatVariables.ts
|
||||
|
||||
import { ContributionProvider, Disposable, Emitter, ILogger, MaybePromise, Prioritizeable, Event } from '@theia/core';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import { PromptText } from './prompt-text';
|
||||
|
||||
/**
|
||||
* A variable is a short string that is used to reference a value that is resolved and replaced in the user prompt at request-time.
|
||||
*/
|
||||
export interface AIVariable {
|
||||
/** provider id */
|
||||
id: string;
|
||||
/** variable name, used for referencing variables in the chat */
|
||||
name: string;
|
||||
/** variable description */
|
||||
description: string;
|
||||
/** optional label, used for showing the variable in the UI. If not provided, the variable name is used */
|
||||
label?: string;
|
||||
/** optional icon classes, used for showing the variable in the UI. */
|
||||
iconClasses?: string[];
|
||||
/** specifies whether this variable contributes to the context -- @see ResolvedAIContextVariable */
|
||||
isContextVariable?: boolean;
|
||||
/** optional arguments for resolving the variable into a value */
|
||||
args?: AIVariableDescription[];
|
||||
}
|
||||
|
||||
export namespace AIVariable {
|
||||
export function is(arg: unknown): arg is AIVariable {
|
||||
return !!arg && typeof arg === 'object' &&
|
||||
'id' in arg &&
|
||||
'name' in arg &&
|
||||
'description' in arg;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIContextVariable extends AIVariable {
|
||||
label: string;
|
||||
isContextVariable: true;
|
||||
}
|
||||
|
||||
export namespace AIContextVariable {
|
||||
export function is(arg: unknown): arg is AIContextVariable {
|
||||
return AIVariable.is(arg) && 'isContextVariable' in arg && arg.isContextVariable === true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIVariableDescription {
|
||||
name: string;
|
||||
description: string;
|
||||
enum?: string[];
|
||||
isOptional?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAIVariable {
|
||||
variable: AIVariable;
|
||||
arg?: string;
|
||||
/** value that is inserted into the prompt at the position of the variable usage */
|
||||
value: string;
|
||||
/** Flat list of all variables that have been (recursively) resolved while resolving this variable. */
|
||||
allResolvedDependencies?: ResolvedAIVariable[];
|
||||
}
|
||||
|
||||
export namespace ResolvedAIVariable {
|
||||
export function is(arg: unknown): arg is ResolvedAIVariable {
|
||||
return !!arg && typeof arg === 'object' &&
|
||||
'variable' in arg &&
|
||||
'value' in arg &&
|
||||
typeof (arg as { variable: unknown }).variable === 'object' &&
|
||||
typeof (arg as { value: unknown }).value === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A context variable is a variable that also contributes to the context of a chat request.
|
||||
*
|
||||
* In contrast to a plain variable, it can also be attached to a request and is resolved into a context value.
|
||||
* The context value is put into the `ChatRequestModel.context`, available to the processing chat agent for further
|
||||
* processing by the chat agent, or invoked tool functions.
|
||||
*/
|
||||
export interface ResolvedAIContextVariable extends ResolvedAIVariable {
|
||||
contextValue: string;
|
||||
}
|
||||
|
||||
export namespace ResolvedAIContextVariable {
|
||||
export function is(arg: unknown): arg is ResolvedAIContextVariable {
|
||||
return ResolvedAIVariable.is(arg) &&
|
||||
'contextValue' in arg &&
|
||||
typeof (arg as { contextValue: unknown }).contextValue === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIVariableResolutionRequest {
|
||||
variable: AIVariable;
|
||||
arg?: string;
|
||||
}
|
||||
|
||||
export namespace AIVariableResolutionRequest {
|
||||
export function is(arg: unknown): arg is AIVariableResolutionRequest {
|
||||
return !!arg && typeof arg === 'object' &&
|
||||
'variable' in arg &&
|
||||
typeof (arg as { variable: { name: unknown } }).variable.name === 'string';
|
||||
}
|
||||
|
||||
export function fromResolved(arg: ResolvedAIContextVariable): AIVariableResolutionRequest {
|
||||
return {
|
||||
variable: arg.variable,
|
||||
arg: arg.arg
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface AIVariableContext {
|
||||
}
|
||||
|
||||
export type AIVariableArg = string | { variable: string, arg?: string } | AIVariableResolutionRequest;
|
||||
|
||||
export type AIVariableArgPicker = (context: AIVariableContext) => MaybePromise<string | undefined>;
|
||||
export type AIVariableArgCompletionProvider =
|
||||
(model: monaco.editor.ITextModel, position: monaco.Position, matchString?: string) => MaybePromise<monaco.languages.CompletionItem[] | undefined>;
|
||||
|
||||
export interface AIVariableResolver {
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number>,
|
||||
resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined>;
|
||||
}
|
||||
|
||||
export interface AIVariableOpener {
|
||||
canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number>;
|
||||
open(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AIVariableResolverWithVariableDependencies extends AIVariableResolver {
|
||||
resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined>;
|
||||
/**
|
||||
* Resolve the given AI variable resolution request. When resolving dependencies with `resolveDependency`,
|
||||
* add the resolved dependencies to the result's `allResolvedDependencies` list
|
||||
* to enable consumers of the resolved variable to inspect dependencies.
|
||||
*/
|
||||
resolve(
|
||||
request: AIVariableResolutionRequest,
|
||||
context: AIVariableContext,
|
||||
resolveDependency: (variable: AIVariableArg) => Promise<ResolvedAIVariable | undefined>
|
||||
): Promise<ResolvedAIVariable | undefined>;
|
||||
}
|
||||
|
||||
function isResolverWithDependencies(resolver: AIVariableResolver | undefined): resolver is AIVariableResolverWithVariableDependencies {
|
||||
return resolver !== undefined && resolver.resolve.length >= 3;
|
||||
}
|
||||
|
||||
export const AIVariableService = Symbol('AIVariableService');
|
||||
export interface AIVariableService {
|
||||
hasVariable(name: string): boolean;
|
||||
getVariable(name: string): Readonly<AIVariable> | undefined;
|
||||
getVariables(): Readonly<AIVariable>[];
|
||||
getContextVariables(): Readonly<AIContextVariable>[];
|
||||
registerVariable(variable: AIVariable): Disposable;
|
||||
unregisterVariable(name: string): void;
|
||||
readonly onDidChangeVariables: Event<void>;
|
||||
|
||||
registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable;
|
||||
unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void;
|
||||
getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise<AIVariableResolver | undefined>;
|
||||
resolveVariable(variable: AIVariableArg, context: AIVariableContext, cache?: Map<string, ResolveAIVariableCacheEntry>): Promise<ResolvedAIVariable | undefined>;
|
||||
|
||||
registerArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): Disposable;
|
||||
unregisterArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): void;
|
||||
getArgumentPicker(name: string, context: AIVariableContext): Promise<AIVariableArgPicker | undefined>;
|
||||
|
||||
registerArgumentCompletionProvider(variable: AIVariable, argPicker: AIVariableArgCompletionProvider): Disposable;
|
||||
unregisterArgumentCompletionProvider(variable: AIVariable, argPicker: AIVariableArgCompletionProvider): void;
|
||||
getArgumentCompletionProvider(name: string): Promise<AIVariableArgCompletionProvider | undefined>;
|
||||
}
|
||||
|
||||
/** Contributions on the frontend can optionally implement `FrontendVariableContribution`. */
|
||||
export const AIVariableContribution = Symbol('AIVariableContribution');
|
||||
export interface AIVariableContribution {
|
||||
registerVariables(service: AIVariableService): void;
|
||||
}
|
||||
|
||||
export interface ResolveAIVariableCacheEntry {
|
||||
promise: Promise<ResolvedAIVariable | undefined>;
|
||||
inProgress: boolean;
|
||||
}
|
||||
|
||||
export type ResolveAIVariableCache = Map<string, ResolveAIVariableCacheEntry>;
|
||||
/**
|
||||
* Creates a new, empty cache for AI variable resolution to hand into `AIVariableService.resolveVariable`.
|
||||
*/
|
||||
export function createAIResolveVariableCache(): Map<string, ResolveAIVariableCacheEntry> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/** Utility function to get all resolved AI variables from a {@link ResolveAIVariableCache} */
|
||||
export async function getAllResolvedAIVariables(cache: ResolveAIVariableCache): Promise<ResolvedAIVariable[]> {
|
||||
const resolvedVariables: ResolvedAIVariable[] = [];
|
||||
for (const cacheEntry of cache.values()) {
|
||||
if (!cacheEntry.inProgress) {
|
||||
const resolvedVariable = await cacheEntry.promise;
|
||||
if (resolvedVariable) {
|
||||
resolvedVariables.push(resolvedVariable);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolvedVariables;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class DefaultAIVariableService implements AIVariableService {
|
||||
protected variables = new Map<string, AIVariable>();
|
||||
protected resolvers = new Map<string, AIVariableResolver[]>();
|
||||
protected argPickers = new Map<string, AIVariableArgPicker>();
|
||||
protected openers = new Map<string, AIVariableOpener[]>();
|
||||
protected argCompletionProviders = new Map<string, AIVariableArgCompletionProvider>();
|
||||
|
||||
protected readonly onDidChangeVariablesEmitter = new Emitter<void>();
|
||||
readonly onDidChangeVariables: Event<void> = this.onDidChangeVariablesEmitter.event;
|
||||
|
||||
constructor(
|
||||
@inject(ContributionProvider) @named(AIVariableContribution)
|
||||
protected readonly contributionProvider: ContributionProvider<AIVariableContribution>,
|
||||
@inject(ILogger) protected readonly logger: ILogger
|
||||
) { }
|
||||
|
||||
protected initContributions(): void {
|
||||
this.contributionProvider.getContributions().forEach(contribution => contribution.registerVariables(this));
|
||||
}
|
||||
|
||||
protected getKey(name: string): string {
|
||||
return `${name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
async getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise<AIVariableResolver | undefined> {
|
||||
const resolvers = await this.prioritize(name, arg, context);
|
||||
return resolvers[0];
|
||||
}
|
||||
|
||||
protected getResolvers(name: string): AIVariableResolver[] {
|
||||
return this.resolvers.get(this.getKey(name)) ?? [];
|
||||
}
|
||||
|
||||
protected async prioritize(name: string, arg: string | undefined, context: AIVariableContext): Promise<AIVariableResolver[]> {
|
||||
const variable = this.getVariable(name);
|
||||
if (!variable) {
|
||||
return [];
|
||||
}
|
||||
const prioritized = await Prioritizeable.prioritizeAll(this.getResolvers(name), async resolver => {
|
||||
try {
|
||||
return await resolver.canResolve({ variable, arg }, context);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return prioritized.map(p => p.value);
|
||||
}
|
||||
|
||||
hasVariable(name: string): boolean {
|
||||
return !!this.getVariable(name);
|
||||
}
|
||||
|
||||
getVariable(name: string): Readonly<AIVariable> | undefined {
|
||||
return this.variables.get(this.getKey(name));
|
||||
}
|
||||
|
||||
getVariables(): Readonly<AIVariable>[] {
|
||||
return [...this.variables.values()];
|
||||
}
|
||||
|
||||
getContextVariables(): Readonly<AIContextVariable>[] {
|
||||
return this.getVariables().filter(AIContextVariable.is);
|
||||
}
|
||||
|
||||
registerVariable(variable: AIVariable): Disposable {
|
||||
const key = this.getKey(variable.name);
|
||||
if (!this.variables.get(key)) {
|
||||
this.variables.set(key, variable);
|
||||
this.onDidChangeVariablesEmitter.fire();
|
||||
return Disposable.create(() => this.unregisterVariable(variable.name));
|
||||
}
|
||||
return Disposable.NULL;
|
||||
}
|
||||
|
||||
registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable {
|
||||
this.registerVariable(variable);
|
||||
const key = this.getKey(variable.name);
|
||||
const resolvers = this.resolvers.get(key) ?? [];
|
||||
resolvers.push(resolver);
|
||||
this.resolvers.set(key, resolvers);
|
||||
return Disposable.create(() => this.unregisterResolver(variable, resolver));
|
||||
}
|
||||
|
||||
unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void {
|
||||
const key = this.getKey(variable.name);
|
||||
const registeredResolvers = this.resolvers.get(key);
|
||||
registeredResolvers?.splice(registeredResolvers.indexOf(resolver), 1);
|
||||
if (registeredResolvers?.length === 0) {
|
||||
this.unregisterVariable(variable.name);
|
||||
}
|
||||
}
|
||||
|
||||
unregisterVariable(name: string): void {
|
||||
this.variables.delete(this.getKey(name));
|
||||
this.resolvers.delete(this.getKey(name));
|
||||
this.onDidChangeVariablesEmitter.fire();
|
||||
}
|
||||
|
||||
registerArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): Disposable {
|
||||
this.registerVariable(variable);
|
||||
const key = this.getKey(variable.name);
|
||||
this.argPickers.set(key, argPicker);
|
||||
return Disposable.create(() => this.unregisterArgumentPicker(variable, argPicker));
|
||||
}
|
||||
|
||||
unregisterArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): void {
|
||||
const key = this.getKey(variable.name);
|
||||
const registeredArgPicker = this.argPickers.get(key);
|
||||
if (registeredArgPicker === argPicker) {
|
||||
this.argPickers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async getArgumentPicker(name: string): Promise<AIVariableArgPicker | undefined> {
|
||||
return this.argPickers.get(this.getKey(name)) ?? undefined;
|
||||
}
|
||||
|
||||
registerArgumentCompletionProvider(variable: AIVariable, completionProvider: AIVariableArgCompletionProvider): Disposable {
|
||||
this.registerVariable(variable);
|
||||
const key = this.getKey(variable.name);
|
||||
this.argCompletionProviders.set(key, completionProvider);
|
||||
return Disposable.create(() => this.unregisterArgumentCompletionProvider(variable, completionProvider));
|
||||
}
|
||||
|
||||
unregisterArgumentCompletionProvider(variable: AIVariable, completionProvider: AIVariableArgCompletionProvider): void {
|
||||
const key = this.getKey(variable.name);
|
||||
const registeredCompletionProvider = this.argCompletionProviders.get(key);
|
||||
if (registeredCompletionProvider === completionProvider) {
|
||||
this.argCompletionProviders.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async getArgumentCompletionProvider(name: string): Promise<AIVariableArgCompletionProvider | undefined> {
|
||||
return this.argCompletionProviders.get(this.getKey(name)) ?? undefined;
|
||||
}
|
||||
|
||||
protected parseRequest(request: AIVariableArg): { variableName: string, arg: string | undefined } {
|
||||
const variableName = typeof request === 'string'
|
||||
? request
|
||||
: typeof request.variable === 'string'
|
||||
? request.variable
|
||||
: request.variable.name;
|
||||
const arg = typeof request === 'string' ? undefined : request.arg;
|
||||
return { variableName, arg };
|
||||
}
|
||||
|
||||
async resolveVariable(
|
||||
request: AIVariableArg,
|
||||
context: AIVariableContext,
|
||||
cache: ResolveAIVariableCache = createAIResolveVariableCache()
|
||||
): Promise<ResolvedAIVariable | undefined> {
|
||||
// Calculate unique variable cache key from variable name and argument
|
||||
const { variableName, arg } = this.parseRequest(request);
|
||||
const cacheKey = `${variableName}${PromptText.VARIABLE_SEPARATOR_CHAR}${arg ?? ''}`;
|
||||
|
||||
// If the current cache key exists and is still in progress, we reached a cycle.
|
||||
// If we reach it but it has been resolved, it was part of another resolution branch and we can simply return it.
|
||||
if (cache.has(cacheKey)) {
|
||||
const existingEntry = cache.get(cacheKey)!;
|
||||
if (existingEntry.inProgress) {
|
||||
this.logger.warn(`Cycle detected for variable: ${variableName} with arg: ${arg}. Skipping resolution.`);
|
||||
return undefined;
|
||||
}
|
||||
return existingEntry.promise;
|
||||
}
|
||||
|
||||
const entry: ResolveAIVariableCacheEntry = { promise: this.doResolve(variableName, arg, context, cache), inProgress: true };
|
||||
entry.promise.finally(() => entry.inProgress = false);
|
||||
cache.set(cacheKey, entry);
|
||||
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously resolves a variable, handling its dependencies while preventing cyclical resolution.
|
||||
* Selects the appropriate resolver and resolution strategy based on whether nested dependency resolution is supported.
|
||||
*/
|
||||
protected async doResolve(variableName: string, arg: string | undefined, context: AIVariableContext, cache: ResolveAIVariableCache): Promise<ResolvedAIVariable | undefined> {
|
||||
const variable = this.getVariable(variableName);
|
||||
if (!variable) {
|
||||
return undefined;
|
||||
}
|
||||
const resolver = await this.getResolver(variableName, arg, context);
|
||||
let resolved: ResolvedAIVariable | undefined;
|
||||
if (isResolverWithDependencies(resolver)) {
|
||||
// Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time
|
||||
resolved = await (resolver as AIVariableResolverWithVariableDependencies).resolve(
|
||||
{ variable, arg },
|
||||
context,
|
||||
async (depRequest: AIVariableResolutionRequest) =>
|
||||
this.resolveVariable(depRequest, context, cache)
|
||||
);
|
||||
} else if (resolver) {
|
||||
// Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time
|
||||
resolved = await (resolver as AIVariableResolver).resolve({ variable, arg }, context);
|
||||
} else {
|
||||
resolved = undefined;
|
||||
}
|
||||
return resolved ? { ...resolved, arg } : undefined;
|
||||
}
|
||||
}
|
||||
116
packages/ai-core/src/node/ai-core-backend-module.ts
Normal file
116
packages/ai-core/src/node/ai-core-backend-module.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
LanguageModelFrontendDelegateImpl,
|
||||
LanguageModelRegistryFrontendDelegateImpl,
|
||||
} from './language-model-frontend-delegate';
|
||||
import {
|
||||
ConnectionHandler,
|
||||
PreferenceContribution,
|
||||
RpcConnectionHandler,
|
||||
bindContributionProvider,
|
||||
} from '@theia/core';
|
||||
import {
|
||||
ConnectionContainerModule
|
||||
} from '@theia/core/lib/node/messaging/connection-container-module';
|
||||
import {
|
||||
LanguageModelRegistry,
|
||||
LanguageModelProvider,
|
||||
PromptService,
|
||||
PromptServiceImpl,
|
||||
LanguageModelDelegateClient,
|
||||
LanguageModelFrontendDelegate,
|
||||
LanguageModelRegistryFrontendDelegate,
|
||||
languageModelDelegatePath,
|
||||
languageModelRegistryDelegatePath,
|
||||
LanguageModelRegistryClient,
|
||||
TokenUsageService,
|
||||
TokenUsageServiceClient,
|
||||
TOKEN_USAGE_SERVICE_PATH
|
||||
} from '../common';
|
||||
import { BackendLanguageModelRegistryImpl } from './backend-language-model-registry';
|
||||
import { TokenUsageServiceImpl } from './token-usage-service-impl';
|
||||
import { AgentSettingsPreferenceSchema } from '../common/agent-preferences';
|
||||
import { bindAICorePreferences } from '../common/ai-core-preferences';
|
||||
|
||||
// We use a connection module to handle AI services separately for each frontend.
|
||||
const aiCoreConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService, bindFrontendService }) => {
|
||||
bindContributionProvider(bind, LanguageModelProvider);
|
||||
bind(BackendLanguageModelRegistryImpl).toSelf().inSingletonScope();
|
||||
bind(LanguageModelRegistry).toService(BackendLanguageModelRegistryImpl);
|
||||
|
||||
bind(TokenUsageService).to(TokenUsageServiceImpl).inSingletonScope();
|
||||
|
||||
bind(ConnectionHandler)
|
||||
.toDynamicValue(
|
||||
({ container }) =>
|
||||
new RpcConnectionHandler<TokenUsageServiceClient>(
|
||||
TOKEN_USAGE_SERVICE_PATH,
|
||||
client => {
|
||||
const service = container.get<TokenUsageService>(TokenUsageService);
|
||||
service.setClient(client);
|
||||
return service;
|
||||
}
|
||||
)
|
||||
)
|
||||
.inSingletonScope();
|
||||
|
||||
bind(LanguageModelRegistryFrontendDelegate).to(LanguageModelRegistryFrontendDelegateImpl).inSingletonScope();
|
||||
bind(ConnectionHandler)
|
||||
.toDynamicValue(
|
||||
ctx =>
|
||||
new RpcConnectionHandler<LanguageModelRegistryClient>(
|
||||
languageModelRegistryDelegatePath,
|
||||
client => {
|
||||
const registryDelegate = ctx.container.get<LanguageModelRegistryFrontendDelegateImpl>(
|
||||
LanguageModelRegistryFrontendDelegate
|
||||
);
|
||||
registryDelegate.setClient(client);
|
||||
return registryDelegate;
|
||||
}
|
||||
)
|
||||
)
|
||||
.inSingletonScope();
|
||||
|
||||
bind(LanguageModelFrontendDelegateImpl).toSelf().inSingletonScope();
|
||||
bind(LanguageModelFrontendDelegate).toService(LanguageModelFrontendDelegateImpl);
|
||||
bind(ConnectionHandler)
|
||||
.toDynamicValue(
|
||||
({ container }) =>
|
||||
new RpcConnectionHandler<LanguageModelDelegateClient>(
|
||||
languageModelDelegatePath,
|
||||
client => {
|
||||
const service =
|
||||
container.get<LanguageModelFrontendDelegateImpl>(
|
||||
LanguageModelFrontendDelegateImpl
|
||||
);
|
||||
service.setClient(client);
|
||||
return service;
|
||||
}
|
||||
)
|
||||
)
|
||||
.inSingletonScope();
|
||||
|
||||
bind(PromptServiceImpl).toSelf().inSingletonScope();
|
||||
bind(PromptService).toService(PromptServiceImpl);
|
||||
});
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: AgentSettingsPreferenceSchema });
|
||||
bindAICorePreferences(bind);
|
||||
bind(ConnectionContainerModule).toConstantValue(aiCoreConnectionModule);
|
||||
});
|
||||
67
packages/ai-core/src/node/backend-language-model-registry.ts
Normal file
67
packages/ai-core/src/node/backend-language-model-registry.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { DefaultLanguageModelRegistryImpl, LanguageModel, LanguageModelMetaData, LanguageModelRegistryClient } from '../common';
|
||||
|
||||
/**
|
||||
* Notifies a client whenever a model is added or removed
|
||||
*/
|
||||
@injectable()
|
||||
export class BackendLanguageModelRegistryImpl extends DefaultLanguageModelRegistryImpl {
|
||||
|
||||
private client: LanguageModelRegistryClient | undefined;
|
||||
|
||||
setClient(client: LanguageModelRegistryClient): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override addLanguageModels(models: LanguageModel[]): void {
|
||||
const modelsLength = this.languageModels.length;
|
||||
super.addLanguageModels(models);
|
||||
// only notify for models which were really added
|
||||
for (let i = modelsLength; i < this.languageModels.length; i++) {
|
||||
this.client?.languageModelAdded(this.mapToMetaData(this.languageModels[i]));
|
||||
}
|
||||
}
|
||||
|
||||
override removeLanguageModels(ids: string[]): void {
|
||||
super.removeLanguageModels(ids);
|
||||
for (const id of ids) {
|
||||
this.client?.languageModelRemoved(id);
|
||||
}
|
||||
}
|
||||
|
||||
override async patchLanguageModel<T extends LanguageModel = LanguageModel>(id: string, patch: Partial<T>): Promise<void> {
|
||||
await super.patchLanguageModel(id, patch);
|
||||
if (this.client) {
|
||||
this.client.onLanguageModelUpdated(id);
|
||||
}
|
||||
}
|
||||
|
||||
mapToMetaData(model: LanguageModel): LanguageModelMetaData {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
status: model.status,
|
||||
vendor: model.vendor,
|
||||
version: model.version,
|
||||
family: model.family,
|
||||
maxInputTokens: model.maxInputTokens,
|
||||
maxOutputTokens: model.maxOutputTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
packages/ai-core/src/node/language-model-frontend-delegate.ts
Normal file
123
packages/ai-core/src/node/language-model-frontend-delegate.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { CancellationToken, CancellationTokenSource, ILogger, generateUuid } from '@theia/core';
|
||||
import {
|
||||
LanguageModelMetaData,
|
||||
LanguageModelRegistry,
|
||||
isLanguageModelStreamResponse,
|
||||
isLanguageModelTextResponse,
|
||||
LanguageModelStreamResponsePart,
|
||||
LanguageModelDelegateClient,
|
||||
LanguageModelFrontendDelegate,
|
||||
LanguageModelRegistryFrontendDelegate,
|
||||
LanguageModelResponseDelegate,
|
||||
LanguageModelRegistryClient,
|
||||
isLanguageModelParsedResponse,
|
||||
UserRequest,
|
||||
} from '../common';
|
||||
import { BackendLanguageModelRegistryImpl } from './backend-language-model-registry';
|
||||
|
||||
@injectable()
|
||||
export class LanguageModelRegistryFrontendDelegateImpl implements LanguageModelRegistryFrontendDelegate {
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
private registry: BackendLanguageModelRegistryImpl;
|
||||
|
||||
setClient(client: LanguageModelRegistryClient): void {
|
||||
this.registry.setClient(client);
|
||||
}
|
||||
|
||||
async getLanguageModelDescriptions(): Promise<LanguageModelMetaData[]> {
|
||||
return (await this.registry.getLanguageModels()).map(model => this.registry.mapToMetaData(model));
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class LanguageModelFrontendDelegateImpl implements LanguageModelFrontendDelegate {
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
private registry: LanguageModelRegistry;
|
||||
|
||||
@inject(ILogger)
|
||||
private logger: ILogger;
|
||||
|
||||
private frontendDelegateClient: LanguageModelDelegateClient;
|
||||
private requestCancellationTokenMap: Map<string, CancellationTokenSource> = new Map();
|
||||
|
||||
setClient(client: LanguageModelDelegateClient): void {
|
||||
this.frontendDelegateClient = client;
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
this.requestCancellationTokenMap.get(requestId)?.cancel();
|
||||
}
|
||||
|
||||
async request(
|
||||
modelId: string,
|
||||
request: UserRequest,
|
||||
requestId: string,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<LanguageModelResponseDelegate> {
|
||||
const model = await this.registry.getLanguageModel(modelId);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Request was sent to non-existent language model ${modelId}`
|
||||
);
|
||||
}
|
||||
request.tools?.forEach(tool => {
|
||||
tool.handler = async (args_string, ctx) => this.frontendDelegateClient.toolCall(requestId, tool.id, args_string, ctx?.toolCallId);
|
||||
});
|
||||
if (cancellationToken) {
|
||||
const tokenSource = new CancellationTokenSource();
|
||||
cancellationToken = tokenSource.token;
|
||||
this.requestCancellationTokenMap.set(requestId, tokenSource);
|
||||
}
|
||||
const response = await model.request(request, cancellationToken);
|
||||
if (isLanguageModelTextResponse(response) || isLanguageModelParsedResponse(response)) {
|
||||
return response;
|
||||
}
|
||||
if (isLanguageModelStreamResponse(response)) {
|
||||
const delegate = {
|
||||
streamId: generateUuid(),
|
||||
};
|
||||
this.sendTokens(delegate.streamId, response.stream, cancellationToken);
|
||||
return delegate;
|
||||
}
|
||||
this.logger.error(
|
||||
`Received unexpected response from language model ${modelId}. Trying to continue without touching the response.`,
|
||||
response
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
protected sendTokens(id: string, stream: AsyncIterable<LanguageModelStreamResponsePart>, cancellationToken?: CancellationToken): void {
|
||||
(async () => {
|
||||
try {
|
||||
for await (const token of stream) {
|
||||
this.frontendDelegateClient.send(id, token);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancellationToken?.isCancellationRequested) {
|
||||
this.frontendDelegateClient.error(id, e);
|
||||
}
|
||||
} finally {
|
||||
this.frontendDelegateClient.send(id, undefined);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
83
packages/ai-core/src/node/token-usage-service-impl.ts
Normal file
83
packages/ai-core/src/node/token-usage-service-impl.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { TokenUsage, TokenUsageParams, TokenUsageService } from '../common/token-usage-service';
|
||||
import { TokenUsageServiceClient } from '../common/protocol';
|
||||
|
||||
@injectable()
|
||||
export class TokenUsageServiceImpl implements TokenUsageService {
|
||||
private client: TokenUsageServiceClient | undefined;
|
||||
|
||||
/**
|
||||
* Sets the client to notify about token usage changes
|
||||
*/
|
||||
setClient(client: TokenUsageServiceClient | undefined): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
private readonly tokenUsages: TokenUsage[] = [];
|
||||
|
||||
/**
|
||||
* Records token usage for a model interaction.
|
||||
*
|
||||
* @param model The model identifier
|
||||
* @param params Token usage parameters
|
||||
* @returns A promise that resolves when the token usage has been recorded
|
||||
*/
|
||||
async recordTokenUsage(model: string, params: TokenUsageParams): Promise<void> {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: params.inputTokens,
|
||||
cachedInputTokens: params.cachedInputTokens,
|
||||
readCachedInputTokens: params.readCachedInputTokens,
|
||||
outputTokens: params.outputTokens,
|
||||
model,
|
||||
timestamp: new Date(),
|
||||
requestId: params.requestId
|
||||
};
|
||||
|
||||
this.tokenUsages.push(usage);
|
||||
this.client?.notifyTokenUsage(usage);
|
||||
|
||||
let logMessage = `Input Tokens: ${params.inputTokens};`;
|
||||
|
||||
if (params.cachedInputTokens) {
|
||||
logMessage += ` Input Tokens written to cache: ${params.cachedInputTokens};`;
|
||||
}
|
||||
|
||||
if (params.readCachedInputTokens) {
|
||||
logMessage += ` Input Tokens read from cache: ${params.readCachedInputTokens};`;
|
||||
}
|
||||
|
||||
logMessage += ` Output Tokens: ${params.outputTokens}; Model: ${model};`;
|
||||
|
||||
if (params.requestId) {
|
||||
logMessage += `; RequestId: ${params.requestId}`;
|
||||
}
|
||||
|
||||
console.debug(logMessage);
|
||||
// For now we just store in memory
|
||||
// In the future, this could be persisted to disk, a database, or sent to a service
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all token usage records stored in this service.
|
||||
*/
|
||||
async getTokenUsages(): Promise<TokenUsage[]> {
|
||||
return [...this.tokenUsages];
|
||||
}
|
||||
}
|
||||
121
packages/ai-core/src/node/tool-request-parameters.spec.ts
Normal file
121
packages/ai-core/src/node/tool-request-parameters.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { expect } from 'chai';
|
||||
import { ToolRequest } from '../common/language-model';
|
||||
|
||||
describe('isToolRequestParameters', () => {
|
||||
it('should return true for valid ToolRequestParameters', () => {
|
||||
const validParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string'
|
||||
},
|
||||
param2: {
|
||||
type: 'number'
|
||||
}
|
||||
},
|
||||
required: ['param1']
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(validParams)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for ToolRequestParameters without type or anyOf', () => {
|
||||
const paramsWithoutType = {
|
||||
properties: {
|
||||
param1: {
|
||||
description: 'string'
|
||||
}
|
||||
},
|
||||
required: ['param1']
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(paramsWithoutType)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for invalid ToolRequestParameters with wrong property type', () => {
|
||||
const invalidParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 123
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(invalidParams)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if properties is not an object', () => {
|
||||
const invalidParams = {
|
||||
type: 'object',
|
||||
properties: 'not-an-object',
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(invalidParams)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return true if required is missing', () => {
|
||||
const missingRequiredParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(missingRequiredParams)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false if required is not an array', () => {
|
||||
const invalidRequiredParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: 'param1'
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(invalidRequiredParams)).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false if a required field is not a string', () => {
|
||||
const invalidRequiredParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: [123]
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(invalidRequiredParams)).to.be.false;
|
||||
});
|
||||
|
||||
it('should validate properties with anyOf correctly', () => {
|
||||
const paramsWithAnyOf = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
anyOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' }
|
||||
]
|
||||
}
|
||||
},
|
||||
required: ['param1']
|
||||
};
|
||||
expect(ToolRequest.isToolRequestParameters(paramsWithAnyOf)).to.be.true;
|
||||
});
|
||||
});
|
||||
34
packages/ai-core/tsconfig.json
Normal file
34
packages/ai-core/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../configs/base.tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../editor"
|
||||
},
|
||||
{
|
||||
"path": "../filesystem"
|
||||
},
|
||||
{
|
||||
"path": "../monaco"
|
||||
},
|
||||
{
|
||||
"path": "../output"
|
||||
},
|
||||
{
|
||||
"path": "../variable-resolver"
|
||||
},
|
||||
{
|
||||
"path": "../workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user