diff --git a/packages/ai-chat/src/common/chat-request-parser.spec.ts b/packages/ai-chat/src/common/chat-request-parser.spec.ts index 711c85d36bf68..ed3115476039a 100644 --- a/packages/ai-chat/src/common/chat-request-parser.spec.ts +++ b/packages/ai-chat/src/common/chat-request-parser.spec.ts @@ -18,32 +18,42 @@ import * as sinon from 'sinon'; import { ChatAgentServiceImpl } from './chat-agent-service'; import { ChatRequestParserImpl } from './chat-request-parser'; import { ChatAgentLocation } from './chat-agents'; -import { ChatRequest } from './chat-model'; +import { ChatContext, ChatRequest } from './chat-model'; import { expect } from 'chai'; -import { DefaultAIVariableService, ToolInvocationRegistry, ToolInvocationRegistryImpl } from '@theia/ai-core'; +import { AIVariable, DefaultAIVariableService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core'; +import { ILogger, Logger } from '@theia/core'; +import { ParsedChatRequestTextPart, ParsedChatRequestVariablePart } from './parsed-chat-request'; describe('ChatRequestParserImpl', () => { const chatAgentService = sinon.createStubInstance(ChatAgentServiceImpl); const variableService = sinon.createStubInstance(DefaultAIVariableService); - const toolInvocationRegistry: ToolInvocationRegistry = sinon.createStubInstance(ToolInvocationRegistryImpl); - const parser = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry); + const toolInvocationRegistry = sinon.createStubInstance(ToolInvocationRegistryImpl); + const logger: ILogger = sinon.createStubInstance(Logger); + const parser = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger); - it('parses simple text', () => { + beforeEach(() => { + // Reset our stubs before each test + sinon.reset(); + }); + + it('parses simple text', async () => { const req: ChatRequest = { text: 'What is the best pizza topping?' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result.parts).to.deep.contain({ text: 'What is the best pizza topping?', range: { start: 0, endExclusive: 31 } }); }); - it('parses text with variable name', () => { + it('parses text with variable name', async () => { const req: ChatRequest = { text: 'What is the #best pizza topping?' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result).to.deep.contain({ parts: [{ text: 'What is the ', @@ -59,11 +69,12 @@ describe('ChatRequestParserImpl', () => { }); }); - it('parses text with variable name with argument', () => { + it('parses text with variable name with argument', async () => { const req: ChatRequest = { text: 'What is the #best:by-poll pizza topping?' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result).to.deep.contain({ parts: [{ text: 'What is the ', @@ -79,11 +90,12 @@ describe('ChatRequestParserImpl', () => { }); }); - it('parses text with variable name with numeric argument', () => { + it('parses text with variable name with numeric argument', async () => { const req: ChatRequest = { text: '#size-class:2' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result.parts[0]).to.contain( { variableName: 'size-class', @@ -92,11 +104,12 @@ describe('ChatRequestParserImpl', () => { ); }); - it('parses text with variable name with POSIX path argument', () => { + it('parses text with variable name with POSIX path argument', async () => { const req: ChatRequest = { text: '#file:/path/to/file.ext' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result.parts[0]).to.contain( { variableName: 'file', @@ -105,11 +118,12 @@ describe('ChatRequestParserImpl', () => { ); }); - it('parses text with variable name with Win32 path argument', () => { + it('parses text with variable name with Win32 path argument', async () => { const req: ChatRequest = { text: '#file:c:\\path\\to\\file.ext' }; - const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + const context: ChatContext = { variables: [] }; + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); expect(result.parts[0]).to.contain( { variableName: 'file', @@ -117,4 +131,68 @@ describe('ChatRequestParserImpl', () => { } ); }); + + it('resolves variable and extracts tool functions from resolved variable', async () => { + // Set up two test tool requests that will be referenced in the variable content + const testTool1: ToolRequest = { + id: 'testTool1', + name: 'Test Tool 1', + handler: async () => undefined + }; + const testTool2: ToolRequest = { + id: 'testTool2', + name: 'Test Tool 2', + handler: async () => undefined + }; + // Configure the tool registry to return our test tools + toolInvocationRegistry.getFunction.withArgs(testTool1.id).returns(testTool1); + toolInvocationRegistry.getFunction.withArgs(testTool2.id).returns(testTool2); + + // Set up the test variable to include in the request + const testVariable: AIVariable = { + id: 'testVariable', + name: 'testVariable', + description: 'A test variable', + }; + // Configure the variable service to return our test variable + variableService.getVariable.withArgs(testVariable.name).returns(testVariable); + variableService.resolveVariable.withArgs( + { variable: testVariable.name, arg: 'myarg' }, + sinon.match.any + ).resolves({ + variable: testVariable, + arg: 'myarg', + value: 'This is a test with ~testTool1 and ~testTool2', + }); + + // Create a request with the test variable + const req: ChatRequest = { + text: 'Test with #testVariable:myarg' + }; + const context: ChatContext = { variables: [] }; + + // Parse the request + const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context); + + // Verify the variable part contains the correct properties + expect(result.parts.length).to.equal(2); + expect(result.parts[0] instanceof ParsedChatRequestTextPart).to.be.true; + expect(result.parts[1] instanceof ParsedChatRequestVariablePart).to.be.true; + const variablePart = result.parts[1] as ParsedChatRequestVariablePart; + expect(variablePart).to.have.property('resolution'); + expect(variablePart.resolution).to.deep.equal({ + variable: testVariable, + arg: 'myarg', + value: 'This is a test with ~testTool1 and ~testTool2', + } satisfies ResolvedAIVariable); + + // Verify both tool functions were extracted from the variable content + expect(result.toolRequests.size).to.equal(2); + expect(result.toolRequests.has(testTool1.id)).to.be.true; + expect(result.toolRequests.has(testTool2.id)).to.be.true; + + // Verify the result contains the tool requests returned by the registry + expect(result.toolRequests.get(testTool1.id)).to.deep.equal(testTool1); + expect(result.toolRequests.get(testTool2.id)).to.deep.equal(testTool2); + }); }); diff --git a/packages/ai-chat/src/common/chat-request-parser.ts b/packages/ai-chat/src/common/chat-request-parser.ts index 8c21aaa4304c7..34eac25c7cc26 100644 --- a/packages/ai-chat/src/common/chat-request-parser.ts +++ b/packages/ai-chat/src/common/chat-request-parser.ts @@ -22,7 +22,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { ChatAgentService } from './chat-agent-service'; import { ChatAgentLocation } from './chat-agents'; -import { ChatRequest } from './chat-model'; +import { ChatContext, ChatRequest } from './chat-model'; import { chatAgentLeader, chatFunctionLeader, @@ -35,7 +35,8 @@ 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'; +import { ILogger } from '@theia/core'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const functionReg = /^~([\w_\-\.]+)(?=(\s|$|\b))/i; // A ~ tool function @@ -43,7 +44,7 @@ const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #- export const ChatRequestParser = Symbol('ChatRequestParser'); export interface ChatRequestParser { - parseChatRequest(request: ChatRequest, location: ChatAgentLocation): ParsedChatRequest; + parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise; } function offsetRange(start: number, endExclusive: number): OffsetRange { @@ -57,10 +58,43 @@ export class ChatRequestParserImpl { constructor( @inject(ChatAgentService) private readonly agentService: ChatAgentService, @inject(AIVariableService) private readonly variableService: AIVariableService, - @inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry + @inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry, + @inject(ILogger) private readonly logger: ILogger ) { } - parseChatRequest(request: ChatRequest, location: ChatAgentLocation): ParsedChatRequest { + async parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise { + // Parse the request into parts + const { parts, toolRequests, variables } = this.parseParts(request, location); + + // Resolve all variables and add them to the variable parts. + // Parse resolved variable texts again for tool requests. + // These are not added to parts as they are not visible in the initial chat message. + // However, add they need to be added to the result to be considered by the executing agent. + // TODO [recursive variable resolution] collect recursively resolved variables for result + for (const part of parts) { + if (part instanceof ParsedChatRequestVariablePart) { + const resolvedVariable = await this.variableService.resolveVariable( + { variable: part.variableName, arg: part.variableArg }, + context + ); + if (resolvedVariable) { + part.resolution = resolvedVariable; + // Resolve tool requests in resolved variables + this.parseFunctionsFromText(resolvedVariable.value, toolRequests); + } else { + this.logger.warn(`Failed to resolve variable ${part.variableName} for ${location}`); + } + } + } + + return { request, parts, toolRequests, variables }; + } + + protected parseParts(request: ChatRequest, location: ChatAgentLocation): { + parts: ParsedChatRequestPart[]; + toolRequests: Map; + variables: Map; + } { const parts: ParsedChatRequestPart[] = []; const variables = new Map(); const toolRequests = new Map(); @@ -72,7 +106,7 @@ export class ChatRequestParserImpl { if (previousChar.match(/\s/) || i === 0) { if (char === chatFunctionLeader) { - const functionPart = this.tryParseFunction( + const functionPart = this.tryToParseFunction( message.slice(i), i ); @@ -107,8 +141,7 @@ export class ChatRequestParserImpl { if (i !== 0) { // Insert a part for all the text we passed over, then insert the new parsed part const previousPart = parts.at(-1); - const previousPartEnd = - previousPart?.range.endExclusive ?? 0; + const previousPartEnd = previousPart?.range.endExclusive ?? 0; parts.push( new ParsedChatRequestTextPart( offsetRange(previousPartEnd, i), @@ -131,8 +164,26 @@ export class ChatRequestParserImpl { ) ); } + return { parts, toolRequests, variables }; + } - return { request, parts, toolRequests, variables }; + /** + * Parse text for tool requests and add them to the given map + */ + private parseFunctionsFromText(text: string, toolRequests: Map): void { + for (let i = 0; i < text.length; i++) { + const previousChar = i === 0 ? ' ' : text.charAt(i - 1); + const char = text.charAt(i); + + // Check for function markers at start of words + if ((previousChar.match(/\s/) || i === 0) && char === chatFunctionLeader) { + const functionPart = this.tryToParseFunction(text.slice(i), i); + if (functionPart) { + // Add the found tool request to the given map + toolRequests.set(functionPart.toolRequest.id, functionPart.toolRequest); + } + } + } } private tryToParseAgent( @@ -201,8 +252,9 @@ export class ChatRequestParserImpl { return new ParsedChatRequestVariablePart(varRange, name, variableArg); } - private tryParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined { - const nextFunctionMatch = message.match(functionReg); + private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined { + // Support both the and chat and prompt formats for functions + const nextFunctionMatch = message.match(functionReg) || message.match(PROMPT_FUNCTION_REGEX); if (!nextFunctionMatch) { return; } diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index 83d0db1df10a2..3aa29cd91555a 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -35,7 +35,7 @@ import { ChatContext, } from './chat-model'; import { ChatRequestParser } from './chat-request-parser'; -import { ParsedChatRequest, ParsedChatRequestAgentPart, ParsedChatRequestVariablePart } from './parsed-chat-request'; +import { ParsedChatRequest, ParsedChatRequestAgentPart } from './parsed-chat-request'; export interface ChatRequestInvocation { /** @@ -191,7 +191,9 @@ export class ChatServiceImpl implements ChatService { } session.title = request.text; - const parsedRequest = this.chatRequestParser.parseChatRequest(request, session.model.location); + const resolutionContext: ChatSessionContext = { model: session.model }; + const resolvedContext = await this.resolveChatContext(session.model.context.getVariables(), resolutionContext); + const parsedRequest = await this.chatRequestParser.parseChatRequest(request, session.model.location, resolvedContext); const agent = this.getAgent(parsedRequest, session); if (agent === undefined) { @@ -205,25 +207,9 @@ export class ChatServiceImpl implements ChatService { }; } - const resolutionContext: ChatSessionContext = { model: session.model }; - const resolvedContext = await this.resolveChatContext(session.model.context.getVariables(), resolutionContext); const requestModel = session.model.addRequest(parsedRequest, agent?.id, resolvedContext); resolutionContext.request = requestModel; - for (const part of parsedRequest.parts) { - if (part instanceof ParsedChatRequestVariablePart) { - const resolvedVariable = await this.variableService.resolveVariable( - { variable: part.variableName, arg: part.variableArg }, - resolutionContext - ); - if (resolvedVariable) { - part.resolution = resolvedVariable; - } else { - this.logger.warn(`Failed to resolve variable ${part.variableName} for ${session.model.location}`); - } - } - } - let resolveResponseCreated: (responseModel: ChatResponseModel) => void; let resolveResponseCompleted: (responseModel: ChatResponseModel) => void; const invocation: ChatRequestInvocation = { diff --git a/packages/ai-core/src/browser/ai-core-frontend-module.ts b/packages/ai-core/src/browser/ai-core-frontend-module.ts index 5f3624609cd1a..a33982988f0a9 100644 --- a/packages/ai-core/src/browser/ai-core-frontend-module.ts +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -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); @@ -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(); diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index f2ccd165a73d4..448c78ea1b5d3 100644 --- a/packages/ai-core/src/common/prompt-service.ts +++ b/packages/ai-core/src/common/prompt-service.ts @@ -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; + + /** + * 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 | undefined>; + /** * Adds a {@link PromptTemplate} to the list of prompts. * @param promptTemplate the prompt template to store @@ -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, context); + 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(); const functionReplacements = functionMatches.map(match => { const completeText = match[0]; @@ -280,16 +284,64 @@ 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 | 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 }, + context?: AIVariableContext + ): 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; diff --git a/packages/ai-core/src/common/prompt-variable-contribution.ts b/packages/ai-core/src/common/prompt-variable-contribution.ts new file mode 100644 index 0000000000000..174f857f3224b --- /dev/null +++ b/packages/ai-core/src/common/prompt-variable-contribution.ts @@ -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 { + 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 { + 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]; + } +}