diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index bba6ded35ad2e..f4383ce38d2d5 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -221,6 +221,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { tooltip: m.tooltip, version: m.version, multiplier: m.multiplier, + multiplierNumeric: m.multiplierNumeric, maxInputTokens: m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, auth, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index a442be07b038b..56b2429570c7d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -25,7 +25,9 @@ import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '. import { isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatModeKind } from '../../common/constants.js'; import { IChatAccessibilityService, IChatWidgetService } from '../chat.js'; +import { triggerConfetti } from '../widget/chatConfetti.js'; import { CHAT_CATEGORY } from './chatActions.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; @@ -75,6 +77,16 @@ export function registerChatTitleActions() { }); item.setVote(ChatAgentVoteDirection.Up); item.setVoteDownReason(undefined); + + const configurationService = accessor.get(IConfigurationService); + const accessibilityService = accessor.get(IAccessibilityService); + if (configurationService.getValue('chat.confettiOnThumbsUp') && !accessibilityService.isMotionReduced()) { + const chatWidgetService = accessor.get(IChatWidgetService); + const widget = chatWidgetService.getWidgetBySessionResource(item.session.sessionResource); + if (widget) { + triggerConfetti(widget.domNode); + } + } } }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 0cc2595296b8f..ce10956057d7b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -285,6 +285,11 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + 'chat.confettiOnThumbsUp': { + type: 'boolean', + description: nls.localize('chat.confettiOnThumbsUp', "Controls whether a confetti animation is shown when clicking the thumbs up button on a chat response."), + default: false, + }, 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 1a0e7e62596fb..621a0fc7e567f 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -821,10 +821,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (tool.impl!.prepareToolInvocation) { const preparePromise = tool.impl!.prepareToolInvocation({ parameters: dto.parameters, + toolCallId: dto.callId, chatRequestId: dto.chatRequestId, chatSessionId: dto.context?.sessionId, chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, + modelId: dto.modelId, forceConfirmationReason: forceConfirmationReason }, token); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts b/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts new file mode 100644 index 0000000000000..83a8d6be039dc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; + +const confettiColors = [ + '#f44336', '#e91e63', '#9c27b0', '#673ab7', + '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4', + '#009688', '#4caf50', '#8bc34a', '#ffeb3b', + '#ffc107', '#ff9800', '#ff5722' +]; + +let activeOverlay: HTMLElement | undefined; + +/** + * Triggers a confetti animation inside the given container element. + */ +export function triggerConfetti(container: HTMLElement) { + if (activeOverlay) { + return; + } + + const overlay = dom.$('.chat-confetti-overlay'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.pointerEvents = 'none'; + overlay.style.overflow = 'hidden'; + overlay.style.zIndex = '1000'; + container.appendChild(overlay); + activeOverlay = overlay; + + const { width, height } = container.getBoundingClientRect(); + for (let i = 0; i < 250; i++) { + const part = dom.$('.chat-confetti-particle'); + part.style.position = 'absolute'; + part.style.width = `${Math.random() * 8 + 4}px`; + part.style.height = `${Math.random() * 8 + 4}px`; + part.style.backgroundColor = confettiColors[Math.floor(Math.random() * confettiColors.length)]; + part.style.borderRadius = Math.random() > 0.5 ? '50%' : '0'; + part.style.left = `${Math.random() * width}px`; + part.style.top = '-10px'; + part.style.opacity = '1'; + + overlay.appendChild(part); + + const targetX = (Math.random() - 0.5) * width * 0.8; + const targetY = Math.random() * height * 0.8 + height * 0.1; + const rotation = Math.random() * 720 - 360; + const duration = Math.random() * 1000 + 1500; + const delay = Math.random() * 400; + + part.animate([ + { + transform: 'translate(0, 0) rotate(0deg)', + opacity: 1 + }, + { + transform: `translate(${targetX * 0.5}px, ${targetY * 0.5}px) rotate(${rotation * 0.5}deg)`, + opacity: 1, + offset: 0.3 + }, + { + transform: `translate(${targetX}px, ${targetY}px) rotate(${rotation}deg)`, + opacity: 1, + offset: 0.75 + }, + { + transform: `translate(${targetX * 1.1}px, ${targetY + 40}px) rotate(${rotation + 30}deg)`, + opacity: 0 + } + ], { + duration, + delay, + easing: 'linear', + fill: 'forwards' + }); + } + + setTimeout(() => { + overlay.remove(); + activeOverlay = undefined; + }, 3000); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index ff0da48563edc..01559966fdfd6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -49,7 +49,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I private title: IMarkdownString | string, context: IChatContentPartRenderContext, private readonly hoverMessage: IMarkdownString | undefined, - @IHoverService private readonly hoverService: IHoverService, + @IHoverService protected readonly hoverService: IHoverService, ) { super(); this.element = context.element; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 25b4bcac405db..429a09fdd41ec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -6,7 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { rcut } from '../../../../../../base/common/strings.js'; import { localize } from '../../../../../../nls.js'; @@ -79,6 +79,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Current tool message for collapsed title (persists even after tool completes) private currentRunningToolMessage: string | undefined; + // Model name used by this subagent for hover tooltip + private modelName: string | undefined; + private readonly _hoverDisposable = this._register(new MutableDisposable()); + // Confirmation auto-expand tracking private toolsWaitingForConfirmation: number = 0; private userManuallyExpanded: boolean = false; @@ -87,11 +91,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen /** * Extracts subagent info (description, agentName, prompt) from a tool invocation. */ - private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined } { + private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined; modelName: string | undefined } { const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); if (toolInvocation.toolId !== RunSubagentTool.Id) { - return { description: defaultDescription, agentName: undefined, prompt: undefined }; + return { description: defaultDescription, agentName: undefined, prompt: undefined, modelName: undefined }; } // Check toolSpecificData first (works for both live and serialized) @@ -100,6 +104,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen description: toolInvocation.toolSpecificData.description ?? defaultDescription, agentName: toolInvocation.toolSpecificData.agentName, prompt: toolInvocation.toolSpecificData.prompt, + modelName: toolInvocation.toolSpecificData.modelName, }; } @@ -113,10 +118,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen description: params?.description ?? defaultDescription, agentName: params?.agentName, prompt: params?.prompt, + modelName: undefined, }; } - return { description: defaultDescription, agentName: undefined, prompt: undefined }; + return { description: defaultDescription, agentName: undefined, prompt: undefined, modelName: undefined }; } constructor( @@ -134,7 +140,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen @IHoverService hoverService: IHoverService, ) { // Extract description, agentName, and prompt from toolInvocation - const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + const { description, agentName, prompt, modelName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); // Build title: "AgentName: description" or "Subagent: description" const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); @@ -144,6 +150,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.description = description; this.agentName = agentName; this.prompt = prompt; + this.modelName = modelName; this.isInitiallyComplete = this.element.isComplete; const node = this.domNode; @@ -201,6 +208,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Scheduler for coalescing layout operations this.layoutScheduler = this._register(new AnimationFrameScheduler(this.domNode, () => this.performLayout())); + // Set up hover tooltip with model name if available + this.updateHover(); + // Render the prompt section at the start if available (must be after wrapper is initialized) this.renderPromptSection(); @@ -327,6 +337,16 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.setTitleWithWidgets(new MarkdownString(finalLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); } + private updateHover(): void { + if (!this.modelName || !this._collapseButton) { + return; + } + + this._hoverDisposable.value = this.hoverService.setupDelayedHover(this._collapseButton.element, { + content: localize('chat.subagent.modelTooltip', 'Model: {0}', this.modelName), + }); + } + /** * Tracks a tool invocation's state for: * 1. Updating the title with the current tool message (persists even after completion) @@ -400,15 +420,25 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.renderResultText(textParts.join('\n')); } + // Update model name from toolSpecificData (set during invoke()) + if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.modelName) { + this.modelName = toolInvocation.toolSpecificData.modelName; + this.updateHover(); + } + // Mark as inactive when the tool completes this.markAsInactive(); } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { wasStreaming = false; // Update things that change when tool is done streaming - const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + const { description, agentName, prompt, modelName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); this.description = description; this.agentName = agentName; this.prompt = prompt; + if (modelName) { + this.modelName = modelName; + this.updateHover(); + } this.renderPromptSection(); this.updateTitle(); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index c56b97b691858..f6f0ae223bcca 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -852,6 +852,7 @@ export interface IChatSubagentToolInvocationData { agentName?: string; prompt?: string; result?: string; + modelName?: string; } export interface IChatTodoListContent { diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index d3366a2de9a24..4067c77f7e79b 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -176,6 +176,7 @@ export interface ILanguageModelChatMetadata { readonly tooltip?: string; readonly detail?: string; readonly multiplier?: string; + readonly multiplierNumeric?: number; readonly family: string; readonly maxInputTokens: number; readonly maxOutputTokens: number; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index ba37834afa7b4..5f2079ea40151 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -16,6 +16,7 @@ export const CLAUDE_HOOK_TYPE_MAP: Record = { 'UserPromptSubmit': HookType.UserPromptSubmit, 'PreToolUse': HookType.PreToolUse, 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, 'SubagentStart': HookType.SubagentStart, 'SubagentStop': HookType.SubagentStop, 'Stop': HookType.Stop, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 9655530d7df2d..f001a9a009321 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -19,6 +19,7 @@ export enum HookType { UserPromptSubmit = 'UserPromptSubmit', PreToolUse = 'PreToolUse', PostToolUse = 'PostToolUse', + PreCompact = 'PreCompact', SubagentStart = 'SubagentStart', SubagentStop = 'SubagentStop', Stop = 'Stop', @@ -53,6 +54,11 @@ export const HOOK_TYPES = [ label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") }, + { + id: HookType.PreCompact, + label: nls.localize('hookType.preCompact.label', "Pre-Compact"), + description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") + }, { id: HookType.SubagentStart, label: nls.localize('hookType.subagentStart.label', "Subagent Start"), @@ -104,6 +110,7 @@ export interface IChatRequestHooks { readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[]; readonly [HookType.PreToolUse]?: readonly IHookCommand[]; readonly [HookType.PostToolUse]?: readonly IHookCommand[]; + readonly [HookType.PreCompact]?: readonly IHookCommand[]; readonly [HookType.SubagentStart]?: readonly IHookCommand[]; readonly [HookType.SubagentStop]?: readonly IHookCommand[]; readonly [HookType.Stop]?: readonly IHookCommand[]; @@ -198,6 +205,10 @@ export const hookFileSchema: IJSONSchema = { ...hookArraySchema, description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') }, + PreCompact: { + ...hookArraySchema, + description: nls.localize('hookFile.preCompact', 'Executed before the agent compacts the conversation context. Use to save conversation state, export important information, or prepare for context reduction.') + }, SubagentStart: { ...hookArraySchema, description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index aecd757949d43..be1e0216699ce 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1012,6 +1012,7 @@ export class PromptsService extends Disposable implements IPromptsService { [HookType.UserPromptSubmit]: [], [HookType.PreToolUse]: [], [HookType.PostToolUse]: [], + [HookType.PreCompact]: [], [HookType.SubagentStart]: [], [HookType.SubagentStop]: [], [HookType.Stop]: [], diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index a71ce6ba3ed6d..5e2a33a7e3d2d 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -60,6 +60,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { readonly onDidUpdateToolData: Event; + /** Hack to port data between prepare/invoke */ + private readonly _resolvedModels = new Map(); + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -143,22 +146,23 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeTools = invocation.userSelectedTools; let modeInstructions: IChatRequestModeInstructions | undefined; let subagent: ICustomAgent | undefined; + let resolvedModelName: string | undefined; const subAgentName = args.agentName; if (subAgentName) { subagent = await this.getSubAgentByName(subAgentName); if (subagent) { - // Use mode-specific model if available - const modeModelQualifiedNames = subagent.model; - if (modeModelQualifiedNames) { - // Find the actual model identifier from the qualified name(s) - outer: for (const qualifiedName of modeModelQualifiedNames) { - const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); - if (lmByQualifiedName?.identifier) { - modeModelId = lmByQualifiedName.identifier; - break outer; - } - } + // Check the pre-resolved model cache from prepareToolInvocation + const cached = this._resolvedModels.get(invocation.callId); + if (cached) { + this._resolvedModels.delete(invocation.callId); + modeModelId = cached.modeModelId; + resolvedModelName = cached.resolvedModelName; + } else { + // Fallback: resolve the model here if prepare didn't cache it + const resolved = this.resolveSubagentModel(subagent, invocation.modelId); + modeModelId = resolved.modeModelId; + resolvedModelName = resolved.resolvedModelName; } // Use mode-specific tools if available @@ -185,6 +189,16 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } else { throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); } + } else { + // No subagent name - clean up any cached entry and resolve model name from main model + const cached = this._resolvedModels.get(invocation.callId); + if (cached) { + this._resolvedModels.delete(invocation.callId); + resolvedModelName = cached.resolvedModelName; + } else { + const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; + resolvedModelName = resolvedModelMetadata?.name; + } } // Track whether we should collect markdown (after the last tool invocation) @@ -274,6 +288,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Store result in toolSpecificData for serialization if (invocation.toolSpecificData?.kind === 'subagent') { invocation.toolSpecificData.result = resultText; + invocation.toolSpecificData.modelName = resolvedModelName; } // Return result with toolMetadata containing subAgentInvocationId for trajectory tracking @@ -286,6 +301,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { subAgentInvocationId, description: args.description, agentName: agentRequest.subAgentName, + modelName: resolvedModelName, } }; @@ -303,11 +319,53 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return agents.find(agent => agent.name === name); } + /** + * Resolves the model to be used by a subagent, applying multiplier-based + * fallback to avoid using a more expensive model than the main agent. + */ + private resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined): { modeModelId: string | undefined; resolvedModelName: string | undefined } { + let modeModelId = mainModelId; + + if (subagent) { + const modeModelQualifiedNames = subagent.model; + if (modeModelQualifiedNames) { + // Find the actual model identifier from the qualified name(s) + outer: for (const qualifiedName of modeModelQualifiedNames) { + const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); + if (lmByQualifiedName?.identifier) { + modeModelId = lmByQualifiedName.identifier; + break outer; + } + } + } + + // If the subagent's model has a larger multiplier than the main agent's model, + // fall back to the main agent's model to avoid using a more expensive model. + if (modeModelId && modeModelId !== mainModelId) { + const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; + const subagentModelMetadata = this.languageModelsService.lookupLanguageModel(modeModelId); + const mainMultiplier = mainModelMetadata?.multiplierNumeric; + const subagentMultiplier = subagentModelMetadata?.multiplierNumeric; + if (mainMultiplier !== undefined && subagentMultiplier !== undefined && subagentMultiplier > mainMultiplier) { + this.logService.warn(`[RunSubagentTool] Subagent '${subagent.name}' requested model '${subagentModelMetadata?.name}' (multiplier: ${subagentMultiplier}) which has a larger multiplier than the main agent model '${mainModelMetadata?.name}' (multiplier: ${mainMultiplier}). Falling back to the main agent model.`); + modeModelId = mainModelId; + } + } + } + + const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; + return { modeModelId, resolvedModelName: resolvedModelMetadata?.name }; + } + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined; + // Resolve the model early and cache it for invoke() + const resolved = this.resolveSubagentModel(subagent, context.modelId); + this._resolvedModels.set(context.toolCallId, resolved); + return { invocationMessage: args.description, toolSpecificData: { @@ -315,6 +373,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { description: args.description, agentName: subagent?.name, prompt: args.prompt, + modelName: resolved.resolvedModelName, }, }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 3f187cf003f91..e7a26991320fa 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -200,12 +200,14 @@ export function isToolInvocationContext(obj: any): obj is IToolInvocationContext export interface IToolInvocationPreparationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any parameters: any; + toolCallId: string; chatRequestId?: string; /** @deprecated Use {@link chatSessionResource} instead */ chatSessionId?: string; chatSessionResource: URI | undefined; chatInteractionId?: string; - /** If set, tells the tool that it should include confirmmation messages. */ + modelId?: string; + /** If set, tells the tool that it should include confirmation messages. */ forceConfirmationReason?: string; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 4473ad266aade..e7d7f6730a2ed 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -1364,4 +1364,96 @@ suite('ChatSubagentContentPart', () => { 'Title should still include tool message after completion'); }); }); + + suite('Model name tooltip', () => { + test('should set up hover with model name from serialized toolSpecificData', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const serializedInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + agentName: 'TestAgent', + prompt: 'Do the thing', + result: 'Done', + modelName: 'GPT-4o' + } + }); + const context = createMockRenderContext(true); + + createPart(serializedInvocation, context); + + // Should have set up a hover with the model name + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('GPT-4o')); + assert.ok(modelHover, 'Should set up hover with model name'); + }); + + test('should not set up hover when no model name is available', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const serializedInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + agentName: 'TestAgent', + prompt: 'Do the thing', + result: 'Done', + // no modelName + } + }); + const context = createMockRenderContext(true); + + createPart(serializedInvocation, context); + + // Should not have set up any hover with model info + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('Model:')); + assert.strictEqual(modelHover, undefined, 'Should not set up model hover when no model name'); + }); + + test('should set up hover when tool completes and toolSpecificData has modelName', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const toolSpecificData: IChatSubagentToolInvocationData = { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent', + prompt: 'Do stuff', + }; + + const toolInvocation = createMockToolInvocation({ + toolSpecificData, + stateType: IChatToolInvocation.StateKind.Executing, + }); + const context = createMockRenderContext(false); + + createPart(toolInvocation, context); + + // No model hover initially (no modelName yet) + const initialHover = setupDelayedHoverCalls.find(c => c.content.includes('Model:')); + assert.strictEqual(initialHover, undefined, 'Should not have model hover initially'); + + // Simulate invoke() setting modelName on toolSpecificData + toolSpecificData.modelName = 'Claude Sonnet 4'; + + // Simulate tool completion + const state = toolInvocation.state as ReturnType>; + state.set(createState(IChatToolInvocation.StateKind.Completed), undefined); + + // Should now have a hover with the model name + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('Claude Sonnet 4')); + assert.ok(modelHover, 'Should set up hover with model name after completion'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 25d7088d46e17..a9811b001db48 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -13,10 +13,11 @@ import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubage import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; -import { ILanguageModelsService } from '../../../../common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -77,6 +78,7 @@ suite('RunSubagentTool', () => { description: 'Test task', agentName: 'CustomAgent', }, + toolCallId: 'test-call-1', chatSessionResource: URI.parse('test://session'), }, CancellationToken.None @@ -89,6 +91,7 @@ suite('RunSubagentTool', () => { description: 'Test task', agentName: 'CustomAgent', prompt: 'Test prompt', + modelName: undefined, }); }); }); @@ -208,4 +211,276 @@ suite('RunSubagentTool', () => { assert.deepStrictEqual(matchingEvents, ['matching-tool']); }); }); + + suite('model fallback behavior', () => { + function createMetadata(name: string, multiplierNumeric?: number): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.extension'), + name, + id: name.toLowerCase().replace(/\s+/g, '-'), + vendor: 'TestVendor', + version: '1.0', + family: 'test', + maxInputTokens: 128000, + maxOutputTokens: 8192, + isDefaultForLocation: {}, + modelPickerCategory: undefined, + multiplierNumeric, + }; + } + + function createTool(opts: { + models: Map; + qualifiedNameMap?: Map; + customAgents?: ICustomAgent[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService(); + const promptsService = new MockPromptsService(); + if (opts.customAgents) { + promptsService.setCustomModes(opts.customAgents); + } + + const mockLanguageModelsService: Partial = { + lookupLanguageModel(modelId: string) { + return opts.models.get(modelId); + }, + lookupLanguageModelByQualifiedName(qualifiedName: string) { + return opts.qualifiedNameMap?.get(qualifiedName); + }, + }; + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + mockToolsService, + mockLanguageModelsService as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + promptsService, + {} as IInstantiationService, + )); + + return tool; + } + + function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { + return { + uri: URI.parse(`file:///test/${name}.md`), + name, + description: `Agent ${name}`, + tools: ['tool1'], + model: modelQualifiedNames, + agentInstructions: { content: 'test', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + visibility: { userInvokable: true, agentInvokable: true } + }; + } + + test('falls back to main model when subagent model has higher multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const expensiveMeta = createMetadata('O3 Pro', 50); + const models = new Map([ + ['main-model-id', mainMeta], + ['expensive-model-id', expensiveMeta], + ]); + const qualifiedNameMap = new Map([ + ['O3 Pro (TestVendor)', { metadata: expensiveMeta, identifier: 'expensive-model-id' }], + ]); + + const agent = createAgent('ExpensiveAgent', ['O3 Pro (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'ExpensiveAgent' }, + toolCallId: 'call-1', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // Should fall back to the main model's name, not the expensive model + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'ExpensiveAgent', + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + + test('uses subagent model when it has equal multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const sameCostMeta = createMetadata('Claude Sonnet', 1); + const models = new Map([ + ['main-model-id', mainMeta], + ['same-cost-model-id', sameCostMeta], + ]); + const qualifiedNameMap = new Map([ + ['Claude Sonnet (TestVendor)', { metadata: sameCostMeta, identifier: 'same-cost-model-id' }], + ]); + + const agent = createAgent('SameCostAgent', ['Claude Sonnet (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'SameCostAgent' }, + toolCallId: 'call-2', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'SameCostAgent', + prompt: 'test', + modelName: 'Claude Sonnet', + }); + }); + + test('uses subagent model when it has lower multiplier', async () => { + const mainMeta = createMetadata('O3 Pro', 50); + const cheapMeta = createMetadata('GPT-4o Mini', 0.25); + const models = new Map([ + ['main-model-id', mainMeta], + ['cheap-model-id', cheapMeta], + ]); + const qualifiedNameMap = new Map([ + ['GPT-4o Mini (TestVendor)', { metadata: cheapMeta, identifier: 'cheap-model-id' }], + ]); + + const agent = createAgent('CheapAgent', ['GPT-4o Mini (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'CheapAgent' }, + toolCallId: 'call-3', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'CheapAgent', + prompt: 'test', + modelName: 'GPT-4o Mini', + }); + }); + + test('uses subagent model when main model has no multiplier', async () => { + const mainMeta = createMetadata('Unknown Model', undefined); + const subMeta = createMetadata('O3 Pro', 50); + const models = new Map([ + ['main-model-id', mainMeta], + ['sub-model-id', subMeta], + ]); + const qualifiedNameMap = new Map([ + ['O3 Pro (TestVendor)', { metadata: subMeta, identifier: 'sub-model-id' }], + ]); + + const agent = createAgent('SubAgent', ['O3 Pro (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'SubAgent' }, + toolCallId: 'call-4', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // No fallback when main model's multiplier is unknown + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'SubAgent', + prompt: 'test', + modelName: 'O3 Pro', + }); + }); + + test('uses subagent model when subagent model has no multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const subMeta = createMetadata('Custom Model', undefined); + const models = new Map([ + ['main-model-id', mainMeta], + ['sub-model-id', subMeta], + ]); + const qualifiedNameMap = new Map([ + ['Custom Model (TestVendor)', { metadata: subMeta, identifier: 'sub-model-id' }], + ]); + + const agent = createAgent('CustomAgent', ['Custom Model (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'CustomAgent' }, + toolCallId: 'call-5', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // No fallback when subagent model's multiplier is unknown + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'CustomAgent', + prompt: 'test', + modelName: 'Custom Model', + }); + }); + + test('uses main model when no subagent is specified', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const models = new Map([['main-model-id', mainMeta]]); + + const tool = createTool({ models }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'call-6', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: undefined, + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + + test('uses main model when subagent has no model configured', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const models = new Map([['main-model-id', mainMeta]]); + + const agent = createAgent('NoModelAgent', undefined); + const tool = createTool({ models, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'NoModelAgent' }, + toolCallId: 'call-7', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'NoModelAgent', + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts index acee94cc06c5a..1281978984655 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts @@ -192,7 +192,7 @@ suite('FetchWebPageTool', () => { ); const preparation = await tool.prepareToolInvocation( - { parameters: { urls: ['https://valid.com', 'test://valid/resource', 'invalid://invalid'] }, chatSessionResource: undefined }, + { parameters: { urls: ['https://valid.com', 'test://valid/resource', 'invalid://invalid'] }, toolCallId: 'test-call-1', chatSessionResource: undefined }, CancellationToken.None ); @@ -230,7 +230,7 @@ suite('FetchWebPageTool', () => { ); const preparation1 = await tool.prepareToolInvocation( - { parameters: { urls: ['https://example.com'] }, chatSessionResource: LocalChatSessionUri.forSession('a') }, + { parameters: { urls: ['https://example.com'] }, toolCallId: 'test-call-2', chatSessionResource: LocalChatSessionUri.forSession('a') }, CancellationToken.None ); @@ -238,7 +238,7 @@ suite('FetchWebPageTool', () => { assert.strictEqual(preparation1.confirmationMessages?.title, undefined); const preparation2 = await tool.prepareToolInvocation( - { parameters: { urls: ['https://other.com'] }, chatSessionResource: LocalChatSessionUri.forSession('a') }, + { parameters: { urls: ['https://other.com'] }, toolCallId: 'test-call-3', chatSessionResource: LocalChatSessionUri.forSession('a') }, CancellationToken.None ); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index a222ea6ef6931..929e53541182e 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -10,7 +10,7 @@ declare module 'vscode' { /** * The type of hook to execute. */ - export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SubagentStart' | 'SubagentStop' | 'Stop'; + export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** * Options for executing a hook command. diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 09d3264195fcb..48a3f1048feeb 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -38,6 +38,11 @@ declare module 'vscode' { */ readonly multiplier?: string; + /** + * A numeric form of the `multiplier` label + */ + readonly multiplierNumeric?: number; + /** * Whether or not this will be selected by default in the model picker * NOT BEING FINALIZED