// ***************************************************************************** // 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 { inject, injectable } from '@theia/core/shared/inversify'; import { MaybePromise, ProgressService, URI, generateUuid, Event, nls } from '@theia/core'; import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { AgentService, PromptService, ResolvedPromptFragment } from '@theia/ai-core'; import { CHAT_SESSION_SUMMARY_PROMPT } from '../common/chat-session-summary-agent-prompt'; export interface SummaryMetadata { id?: string; label: string; uri?: URI; sessionId?: string; } export interface Summary extends SummaryMetadata { summary: string; id: string; } export const TaskContextStorageService = Symbol('TaskContextStorageService'); export interface TaskContextStorageService { onDidChange: Event; store(summary: Summary): MaybePromise; getAll(): Summary[]; get(identifier: string): MaybePromise; delete(identifier: string): MaybePromise; open(identifier: string): Promise; } @injectable() export class TaskContextService { protected pendingSummaries = new Map>(); @inject(ChatService) protected readonly chatService: ChatService; @inject(AgentService) protected readonly agentService: AgentService; @inject(PromptService) protected readonly promptService: PromptService; @inject(TaskContextStorageService) protected readonly storageService: TaskContextStorageService; @inject(ProgressService) protected readonly progressService: ProgressService; get onDidChange(): Event { return this.storageService.onDidChange; } getAll(): Array { return this.storageService.getAll(); } async getSummary(sessionIdOrFilePath: string): Promise { const existing = await this.storageService.get(sessionIdOrFilePath); if (existing) { return existing.summary; } const pending = this.pendingSummaries.get(sessionIdOrFilePath); if (pending) { return pending.then(({ summary }) => summary); } const session = this.chatService.getSession(sessionIdOrFilePath); if (session) { return this.summarize(session); } throw new Error('Unable to resolve summary request.'); } /** Returns an ID that can be used to refer to the summary in the future. */ async summarize(session: ChatSession, promptId?: string, agent?: ChatAgent, override = true): Promise { const pending = this.pendingSummaries.get(session.id); if (pending) { return pending.then(({ id }) => id); } const existing = this.getSummaryForSession(session); if (existing && !override) { return existing.id; } const summaryId = generateUuid(); const summaryDeferred = new Deferred(); const progress = await this.progressService.showProgress({ text: nls.localize('theia/ai/chat/taskContextService/summarizeProgressMessage', 'Summarize: {0}', session.title || session.id), options: { location: 'ai-chat' } }); this.pendingSummaries.set(session.id, summaryDeferred.promise); try { const prompt = await this.getSystemPrompt(session, promptId); const newSummary: Summary = { summary: await this.getLlmSummary(session, prompt, agent), label: session.title || session.id, sessionId: session.id, id: summaryId }; await this.storageService.store(newSummary); return summaryId; } catch (err) { summaryDeferred.reject(err); const errorSummary: Summary = { summary: `Summary creation failed: ${err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'}`, label: session.title || session.id, sessionId: session.id, id: summaryId }; await this.storageService.store(errorSummary); throw err; } finally { progress.cancel(); this.pendingSummaries.delete(session.id); } } protected async getLlmSummary(session: ChatSession, prompt: ResolvedPromptFragment | undefined, agent?: ChatAgent): Promise { if (!prompt) { return ''; } agent = agent || this.agentService.getAgents().find((candidate): candidate is ChatAgent => 'invoke' in candidate && typeof candidate.invoke === 'function' && candidate.id === ChatSessionSummaryAgent.ID ); if (!agent) { throw new Error('Unable to identify agent for summary.'); } const model = new MutableChatModel(ChatAgentLocation.Panel); const messages = session.model.getRequests().filter((candidate): candidate is MutableChatRequestModel => candidate instanceof MutableChatRequestModel); messages.forEach(message => model['_hierarchy'].append(message)); const summaryRequest = model.addRequest({ variables: prompt.variables ?? [], request: { text: prompt.text }, parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: prompt.text.length }, prompt.text)], toolRequests: prompt.functionDescriptions ?? new Map() }, agent.id); await agent.invoke(summaryRequest); return summaryRequest.response.response.asDisplayString(); } protected async getSystemPrompt(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id): Promise { const prompt = await this.promptService.getResolvedPromptFragment(promptId || CHAT_SESSION_SUMMARY_PROMPT.id, undefined, { model: session.model }); return prompt; } hasSummary(chatSession: ChatSession): boolean { return !!this.getSummaryForSession(chatSession); } protected getSummaryForSession(chatSession: ChatSession): Summary | undefined { return this.storageService.getAll().find(candidate => candidate.sessionId === chatSession.id); } getLabel(id: string): string | undefined { // Labels are metadata that don't need fresh file reads return this.storageService.getAll().find(s => s.id === id)?.label; } open(id: string): Promise { return this.storageService.open(id); } }