// ***************************************************************************** // 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 { ToolInvocationContext, ToolRequest } from '@theia/ai-core'; import { ILogger } from '@theia/core'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import { ChatToolRequestService, normalizeToolArgs } from '../common/chat-tool-request-service'; import { raceConfirmationWithTimeout } from '../common/chat-tool-confirmation-timeout'; import { MutableChatRequestModel, ToolCallChatResponseContent } from '../common/chat-model'; import { ToolConfirmationMode, ChatToolPreferences, TOOL_CONFIRMATION_TIMEOUT_PREFERENCE } from '../common/chat-tool-preferences'; import { ToolConfirmationManager } from './chat-tool-preference-bindings'; /** * Frontend-specific implementation of ChatToolRequestService that handles tool confirmation */ @injectable() export class FrontendChatToolRequestService extends ChatToolRequestService { @inject(ILogger) @named('ai-chat:FrontendChatToolRequestService') protected readonly logger: ILogger; @inject(ToolConfirmationManager) protected readonly confirmationManager: ToolConfirmationManager; @inject(ChatToolPreferences) protected readonly preferences: ChatToolPreferences; protected override toChatToolRequest(toolRequest: ToolRequest, request: MutableChatRequestModel): ToolRequest { return { ...toolRequest, handler: async (arg_string: string, ctx?: ToolInvocationContext) => { const toolCallId = ctx?.toolCallId; const sessionId = request.session.rootSessionId ?? request.session.id; const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, sessionId, toolRequest); switch (confirmationMode) { case ToolConfirmationMode.DISABLED: return { denied: true, message: `Tool ${toolRequest.id} is disabled` }; case ToolConfirmationMode.ALWAYS_ALLOW: { const toolCallContentAlwaysAllow = this.findToolCallContent(toolRequest, arg_string, request, toolCallId); toolCallContentAlwaysAllow.confirm(); const result = await toolRequest.handler(arg_string, this.createToolContext(request, ToolInvocationContext.create(toolCallContentAlwaysAllow.id))); // Signal completion for immediate UI update. The language model uses Promise.all // for parallel tools, so without this the UI wouldn't update until all tools finish. // The result will be overwritten with the same value when the LLM stream yields it. toolCallContentAlwaysAllow.complete(result); return result; } case ToolConfirmationMode.CONFIRM: default: { const toolCallContent = this.findToolCallContent(toolRequest, arg_string, request, toolCallId); // Check for auto-action hook const autoAction = toolRequest.checkAutoAction?.(arg_string); let confirmed: boolean; if (autoAction?.action === 'allow') { toolCallContent.confirm(); confirmed = await toolCallContent.confirmed; } else if (autoAction?.action === 'deny') { toolCallContent.deny(autoAction.reason); return toolCallContent.result; } else { // No auto-action — needs user confirmation // Session setting overrides global preference const sessionTimeout = request.session.settings?.commonSettings?.confirmationTimeout; const timeoutSeconds = sessionTimeout !== undefined && sessionTimeout > 0 ? sessionTimeout : this.preferences[TOOL_CONFIRMATION_TIMEOUT_PREFERENCE]; toolCallContent.confirmationTimeout = timeoutSeconds; toolCallContent.requestUserConfirmation(); request.response.fireInteractionNeeded(toolCallContent); // Mark the response as waiting for input so the pending confirmation is // surfaced consistently with agent questions (e.g. in the session overview). request.response.waitForInput(); try { confirmed = await raceConfirmationWithTimeout(toolCallContent, timeoutSeconds); } finally { request.response.stopWaitingForInput(); } } if (confirmed) { const result = await toolRequest.handler(arg_string, this.createToolContext(request, ToolInvocationContext.create(toolCallContent.id))); // Signal completion for immediate UI update (see ALWAYS_ALLOW case for details) toolCallContent.complete(result); return result; } else { return toolCallContent.result; } } } } }; } /** * Finds the matching ToolCallChatResponseContent for a tool invocation. * * Matches by: 1) toolCallId, 2) tool name + normalized arguments, 3) fallback by name only. */ protected findToolCallContent( toolRequest: ToolRequest, arguments_: string, request: MutableChatRequestModel, toolCallId?: string ): ToolCallChatResponseContent { const response = request.response.response; const contentArray = response.content; // Match on toolCallId first if LLM made it available if (toolCallId !== undefined) { for (let i = contentArray.length - 1; i >= 0; i--) { const content = contentArray[i]; if (ToolCallChatResponseContent.is(content) && content.id === toolCallId) { return content; } } } // Normalize arguments for comparison to handle differences in empty argument representation const normalizedArguments = normalizeToolArgs(arguments_); // Fall back to matching on tool name and normalized arguments for (let i = contentArray.length - 1; i >= 0; i--) { const content = contentArray[i]; if (ToolCallChatResponseContent.is(content) && content.name === toolRequest.id && normalizeToolArgs(content.arguments) === normalizedArguments) { return content; } } // Fallback: match on tool name only for (let i = contentArray.length - 1; i >= 0; i--) { const content = contentArray[i]; if (ToolCallChatResponseContent.is(content) && content.name === toolRequest.id && !content.finished) { this.logger.warn(`Tool call content for tool ${toolRequest.id} matched by incomplete status fallback. ` + `Expected toolCallId: ${toolCallId}, arguments: ${arguments_}`); return content; } } throw new Error(`Tool call content for tool ${toolRequest.id} not found in the response`); } }