deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

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

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

View 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"
}

View File

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

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

View 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');

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View 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();
}
}

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

View 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 { 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();
}
}

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

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

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

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

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

View 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());
}
}

View File

@@ -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('&lt;tags&gt;');
expect(result.value).to.include('&amp;');
expect(result.value).to.include('&quot;quotes&quot;');
expect(result.value).to.include('&apos;apostrophes&apos;');
});
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&lt;test&gt;</name>');
expect(result.value).to.include('<location>/path/with/&amp;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;
});
});
});

View 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 = '&quot;';
const APOS = '&apos;';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, QUOT)
.replace(/'/g, APOS);
}
}

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

View File

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

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

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

View 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']
}
}
}
};

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

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

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

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

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

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

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

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

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

View File

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

View 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();
}
}

View 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}`;

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

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

View 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,
];

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

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

File diff suppressed because it is too large Load Diff

View 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 = '/';
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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,
};
}
}

View 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);
}
})();
}
}

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

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

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