Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TheiaAi] Support referencing prompt fragments via variable #14985

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/ai-chat/src/common/chat-request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
ParsedChatRequest,
ParsedChatRequestPart,
} from './parsed-chat-request';
import { AIVariable, AIVariableService, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
import { AIVariable, AIVariableService, PROMPT_FUNCTION_REGEX, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';

const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent
const functionReg = /^~([\w_\-\.]+)(?=(\s|$|\b))/i; // A ~ tool function
Expand Down Expand Up @@ -202,7 +202,8 @@ export class ChatRequestParserImpl {
}

private tryParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined {
const nextFunctionMatch = message.match(functionReg);
// Support both the and chat and prompt formats for functions
const nextFunctionMatch = message.match(functionReg) || message.match(PROMPT_FUNCTION_REGEX);
if (!nextFunctionMatch) {
return;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-core/src/browser/ai-core-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { AIActivationService } 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 { PromptVariableContribution } from '../common/prompt-variable-contribution';

export default new ContainerModule(bind => {
bindContributionProvider(bind, LanguageModelProvider);
Expand Down Expand Up @@ -109,6 +110,7 @@ export default new ContainerModule(bind => {
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();
Expand Down
92 changes: 70 additions & 22 deletions packages/ai-core/src/common/prompt-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,27 @@ export interface PromptService {
* Allows to directly replace placeholders in the prompt. The supported format is 'Hi {{name}}!'.
* The placeholder is then searched inside the args object and replaced.
* Function references are also supported via format '~{functionId}'.
*
* All placeholders are replaced before function references are resolved.
* This allows to resolve function references contained in placeholders.
*
* @param id the id of the prompt
* @param args the object with placeholders, mapping the placeholder key to the value
*/
getPrompt(id: string, args?: { [key: string]: unknown }, context?: AIVariableContext): Promise<ResolvedPromptTemplate | undefined>;

/**
* Allows to directly replace placeholders in the prompt. The supported format is 'Hi {{name}}!'.
* The placeholder is then searched inside the args object and replaced.
*
* In contrast to {@link getPrompt}, this method does not resolve function references but leaves them as is.
* This allows resolving them later as part of the prompt or chat message containing the fragment.
*
* @param id the id of the prompt
* @param @param args the object with placeholders, mapping the placeholder key to the value
*/
getPromptFragment(id: string, args?: { [key: string]: unknown }): Promise<Omit<ResolvedPromptTemplate, 'functionDescriptions'> | undefined>;

/**
* Adds a {@link PromptTemplate} to the list of prompts.
* @param promptTemplate the prompt template to store
Expand Down Expand Up @@ -246,27 +263,14 @@ export class PromptServiceImpl implements PromptService {
return undefined;
}

const matches = matchVariablesRegEx(prompt.template);
const variableAndArgReplacements = await Promise.all(matches.map(async match => {
const completeText = match[0];
const variableAndArg = match[1];
let variableName = variableAndArg;
let argument: string | undefined;
const parts = variableAndArg.split(':', 2);
if (parts.length > 1) {
variableName = parts[0];
argument = parts[1];
}
return {
placeholder: completeText,
value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({
variable: variableName,
arg: argument
}, context ?? {}))?.value ?? completeText)
};
}));
// First resolve variables and arguments
let resolvedTemplate = prompt.template;
const variableAndArgReplacements = await this.getVariableAndArgReplacements(prompt.template, args);
variableAndArgReplacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));

const functionMatches = matchFunctionsRegEx(prompt.template);
// Then resolve function references with already resolved variables and arguments
// This allows to resolve function references contained in resolved variables (e.g. prompt fragments)
const functionMatches = matchFunctionsRegEx(resolvedTemplate);
const functions = new Map<string, ToolRequest>();
const functionReplacements = functionMatches.map(match => {
const completeText = match[0];
Expand All @@ -280,16 +284,60 @@ export class PromptServiceImpl implements PromptService {
value: toolRequest ? toolRequestToPromptText(toolRequest) : completeText
};
});
functionReplacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));

return {
id,
text: resolvedTemplate,
functionDescriptions: functions.size > 0 ? functions : undefined
};
}

async getPromptFragment(id: string, args?: { [key: string]: unknown }): Promise<Omit<ResolvedPromptTemplate, 'functionDescriptions'> | undefined> {
const variantId = await this.getVariantId(id);
const prompt = this.getUnresolvedPrompt(variantId);
if (prompt === undefined) {
return undefined;
}

const replacements = await this.getVariableAndArgReplacements(prompt.template, args);
let resolvedTemplate = prompt.template;
const replacements = [...variableAndArgReplacements, ...functionReplacements];
replacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value));
return {
id,
text: resolvedTemplate,
functionDescriptions: functions.size > 0 ? functions : undefined
};
}

/**
* Calculates all variable and argument replacements for an unresolved template.
*
* @param template the unresolved template text
* @param args the object with placeholders, mapping the placeholder key to the value
*/
protected async getVariableAndArgReplacements(template: string, args?: { [key: string]: unknown }): Promise<{ placeholder: string; value: string }[]> {
const matches = matchVariablesRegEx(template);
const variableAndArgReplacements = await Promise.all(matches.map(async match => {
const completeText = match[0];
const variableAndArg = match[1];
let variableName = variableAndArg;
let argument: string | undefined;
const parts = variableAndArg.split(':', 2);
if (parts.length > 1) {
variableName = parts[0];
argument = parts[1];
}
return {
placeholder: completeText,
value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({
variable: variableName,
arg: argument
}, context ?? {}))?.value ?? completeText)
};
}));
return variableAndArgReplacements;
}

getAllPrompts(): PromptMap {
if (this.customizationService !== undefined) {
const myCustomization = this.customizationService;
Expand Down
118 changes: 118 additions & 0 deletions packages/ai-core/src/common/prompt-variable-contribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// *****************************************************************************
// 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 } from '@theia/core';
import { injectable, inject, optional } from '@theia/core/shared/inversify';
import * as monaco from '@theia/monaco-editor-core';
import {
AIVariable,
AIVariableContribution,
AIVariableResolver,
AIVariableService,
AIVariableResolutionRequest,
AIVariableContext,
ResolvedAIContextVariable
} from './variable-service';
import { PromptCustomizationService, 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') }
],
isContextVariable: true
};

@injectable()
export class PromptVariableContribution implements AIVariableContribution, AIVariableResolver {

@inject(PromptService)
protected readonly promptService: PromptService;

@inject(PromptCustomizationService) @optional()
protected readonly promptCustomizationService: PromptCustomizationService;

registerVariables(service: AIVariableService): void {
service.registerResolver(PROMPT_VARIABLE, 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): Promise<ResolvedAIContextVariable | undefined> {
if (request.variable.name === PROMPT_VARIABLE.name) {
const promptId = request.arg?.trim();
if (promptId) {
const resolvedPrompt = await this.promptService.getPromptFragment(promptId);
if (resolvedPrompt) {
return { variable: request.variable, value: resolvedPrompt.text, contextValue: resolvedPrompt.text };
}
}
}
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 customPromptIds = this.promptCustomizationService?.getCustomPromptTemplateIDs() ?? [];
const builtinPromptIds = Object.keys(this.promptService.getAllPrompts());

const customPromptCompletions = customPromptIds.map(promptId => ({
label: promptId,
kind: monaco.languages.CompletionItemKind.Enum,
insertText: promptId,
range,
detail: nls.localize('theia/ai/core/promptVariable/completions/detail/custom', 'Custom prompt template'),
sortText: `AAA${promptId}` // Sort before everything else including all built-in prompts
}));
const builtinPromptCompletions = builtinPromptIds.map(promptId => ({
label: promptId,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: promptId,
range,
detail: nls.localize('theia/ai/core/promptVariable/completions/detail/builtin', 'Built-in prompt template'),
sortText: `AAB${promptId}` // Sort after all custom prompts but before others
}));

return [...customPromptCompletions, ...builtinPromptCompletions];
}
}
Loading