diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 0f2e02380f8..62fc5399dbe 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -997,7 +997,8 @@ "--comment-thread-editor-font-weight", "--comment-thread-state-color", "--comment-thread-state-background-color", - "--inline-edit-border-radius" + "--inline-edit-border-radius", + "--chat-subagent-last-item-height" ], "sizes": [ "--vscode-bodyFontSize", diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index e31c45120fb..146bd1d690f 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -785,6 +785,53 @@ export function lcut(text: string, n: number, prefix = ''): string { return prefix + trimmed.substring(i).trimStart(); } +/** + * Given a string and a max length returns a shortened version keeping the beginning. + * Shortening happens at favorable positions - such as whitespace or punctuation characters. + * Trailing whitespace is always trimmed. + */ +export function rcut(text: string, n: number, suffix = ''): string { + const trimmed = text.trimEnd(); + + if (trimmed.length <= n) { + return trimmed; + } + + const re = /\b/g; + let lastGoodBreak = 0; + let foundBoundaryAfterN = false; + while (re.test(trimmed)) { + if (re.lastIndex > n) { + foundBoundaryAfterN = true; + break; + } + lastGoodBreak = re.lastIndex; + re.lastIndex += 1; + } + + // If no boundary was found after n, return the full trimmed string + // (there's no good place to cut) + if (!foundBoundaryAfterN) { + return trimmed; + } + + // If the only boundary <= n is at position 0 (start of string), + // cutting there gives empty string, so just return the suffix + if (lastGoodBreak === 0) { + return suffix; + } + + const result = trimmed.substring(0, lastGoodBreak).trimEnd(); + + // If trimEnd removed more than half of what we cut (meaning we cut + // mostly through whitespace), return the full string instead + if (result.length < lastGoodBreak / 2) { + return trimmed; + } + + return result + suffix; +} + // Defacto standard: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/; const OSC_SEQUENCE = /(?:\x1b\]|\x9d).*?(?:\x1b\\|\x07|\x9c)/; diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index bb992038f19..aeac4aa7b16 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -196,6 +196,40 @@ suite('Strings', () => { assert.strictEqual(strings.lcut('............a', 10, '…'), '............a'); }); + test('rcut', () => { + assert.strictEqual(strings.rcut('foo bar', 0), ''); + assert.strictEqual(strings.rcut('foo bar', 1), ''); + assert.strictEqual(strings.rcut('foo bar', 3), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 4), 'foo'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5), 'foo'); + assert.strictEqual(strings.rcut('foo bar', 7), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6), 'test'); + + assert.strictEqual(strings.rcut('foo bar', 0, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 1, '…'), '…'); + assert.strictEqual(strings.rcut('foo bar', 3, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 4, '…'), 'foo…'); // Trailing whitespace trimmed + assert.strictEqual(strings.rcut('foo bar', 5, '…'), 'foo…'); + assert.strictEqual(strings.rcut('foo bar', 7, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('foo bar', 10, '…'), 'foo bar'); + assert.strictEqual(strings.rcut('test string 0.1.2.3', 6, '…'), 'test…'); + + assert.strictEqual(strings.rcut('', 10), ''); + assert.strictEqual(strings.rcut('a', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a ', 10), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10), 'a............'); + + assert.strictEqual(strings.rcut('', 10, '…'), ''); + assert.strictEqual(strings.rcut('a', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a ', 10, '…'), 'a'); + assert.strictEqual(strings.rcut('a bbbb ', 10, '…'), 'a bbbb'); + assert.strictEqual(strings.rcut('a............', 10, '…'), 'a............'); + }); + test('escape', () => { assert.strictEqual(strings.escape(''), ''); assert.strictEqual(strings.escape('foo'), 'foo'); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 0d56d40bb74..de412e896a2 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -288,6 +288,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA toolId: progress.toolName, chatRequestId: requestId, sessionResource: chatSession?.sessionResource, + subagentInvocationId: progress.subagentInvocationId }); continue; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c6c821007c4..a2bf7c8df2f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2330,6 +2330,7 @@ export interface IChatBeginToolInvocationDto { streamData?: { partialInput?: unknown; }; + subagentInvocationId?: string; } export interface IChatUpdateToolInvocationDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 1f47b51ee41..4a4d145b638 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -311,7 +311,8 @@ export class ChatAgentResponseStream { toolName, streamData: streamData ? { partialInput: streamData.partialInput - } : undefined + } : undefined, + subagentInvocationId: streamData?.subagentInvocationId }; _report(dto); return this; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f629148a389..fe30b59b2d1 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -125,7 +125,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape context: options.toolInvocationToken as IToolInvocationContext | undefined, chatRequestId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatRequestId : undefined, chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, - fromSubAgent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.fromSubAgent : undefined, + subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, }, token); @@ -186,7 +186,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionId = dto.context?.sessionId; - options.fromSubAgent = dto.fromSubAgent; + options.subAgentInvocationId = dto.subAgentInvocationId; } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index d12a5b9c375..02ae97a4831 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2833,7 +2833,7 @@ export namespace ChatToolInvocationPart { : part.presentation === 'hiddenAfterComplete' ? ToolInvocationPresentation.HiddenAfterComplete : undefined, - fromSubAgent: part.fromSubAgent + subAgentInvocationId: part.subAgentInvocationId }; } @@ -2882,7 +2882,7 @@ export namespace ChatToolInvocationPart { if (part.toolSpecificData) { toolInvocation.toolSpecificData = convertFromInternalToolSpecificData(part.toolSpecificData); } - toolInvocation.fromSubAgent = part.fromSubAgent; + toolInvocation.subAgentInvocationId = part.subAgentInvocationId; return toolInvocation; } @@ -3161,7 +3161,7 @@ export namespace ChatAgentRequest { editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), - isSubagent: request.isSubagent, + subAgentInvocationId: request.subAgentInvocationId, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3182,7 +3182,7 @@ export namespace ChatAgentRequest { // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).sessionId; // eslint-disable-next-line local/code-no-any-casts - delete (requestWithAllProps as any).isSubagent; + delete (requestWithAllProps as any).subAgentInvocationId; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 6277175ffcd..b8d92947c99 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3359,7 +3359,7 @@ export class ChatToolInvocationPart { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData2; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 152b390c9a7..4cb1a09d8c5 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -107,6 +107,8 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi if (toolInvocation.toolSpecificData?.kind === 'terminal') { const terminalData = migrateLegacyTerminalToolSpecificData(toolInvocation.toolSpecificData); input = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; + } else if (toolInvocation.toolSpecificData?.kind === 'subagent') { + input = toolInvocation.toolSpecificData.description ?? ''; } else { input = toolInvocation.toolSpecificData?.kind === 'extensions' ? JSON.stringify(toolInvocation.toolSpecificData.extensions) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index b5c42764ddf..0cceae49ec1 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -390,7 +390,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters); } else { // Create a new tool invocation (no streaming phase) - toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.fromSubAgent, dto.parameters); + toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters); this._chatService.appendProgress(request, toolInvocation); } @@ -590,7 +590,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolCallId: options.toolCallId, toolId: options.toolId, toolData: toolEntry.data, - fromSubAgent: options.fromSubAgent, + subagentInvocationId: options.subagentInvocationId, chatRequestId: options.chatRequestId, }); @@ -602,9 +602,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const model = this._chatService.getSession(options.sessionResource); if (model) { // Find the request by chatRequestId if available, otherwise use the last request - const request = options.chatRequestId + const request = (options.chatRequestId ? model.getRequests().find(r => r.id === options.chatRequestId) - : model.getRequests().at(-1); + : undefined) ?? model.getRequests().at(-1); if (request) { this._chatService.appendProgress(request, invocation); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts new file mode 100644 index 00000000000..1d4d8b9095d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; + +/** + * A collapsible content part that displays markdown content. + * The title is shown in the collapsed state, and the full content is shown when expanded. + */ +export class ChatCollapsibleMarkdownContentPart extends ChatCollapsibleContentPart { + + private contentElement: HTMLElement | undefined; + + constructor( + title: string, + private readonly markdownContent: string, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IHoverService hoverService: IHoverService, + ) { + super(title, context, undefined, hoverService); + this.icon = Codicon.check; + } + + protected override initContent(): HTMLElement { + const wrapper = $('.chat-collapsible-markdown-content.chat-used-context-list'); + + if (this.markdownContent) { + this.contentElement = $('.chat-collapsible-markdown-body'); + const rendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(this.markdownContent), { + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + })); + this.contentElement.appendChild(rendered.element); + wrapper.appendChild(this.contentElement); + } + + return wrapper; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // This part is embedded in the subagent part, not rendered directly + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 6e26df575f1..8e571e80b46 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -278,7 +278,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return ref.object.element; } else { const requestId = isRequestVM(element) ? element.id : element.requestId; - const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri, this.markdown.fromSubagent); + const ref = this.renderCodeBlockPill(element.sessionResource, requestId, inUndoStop, codeBlockInfo.codemapperUri); if (isResponseVM(codeBlockInfo.element)) { // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously this.codeBlockModelCollection.update(codeBlockInfo.element.sessionResource, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { @@ -382,10 +382,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } - private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined, fromSubagent?: boolean): IDisposableReference { + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { - codeBlock.render(codemapperUri, fromSubagent); + codeBlock.render(codemapperUri); } return { object: codeBlock, @@ -551,9 +551,7 @@ export class CollapsedCodeBlock extends Disposable { * @param uri URI of the file on-disk being changed * @param isStreaming Whether the edit has completed (at the time of this being rendered) */ - render(uri: URI, fromSubagent?: boolean): void { - this.pillElement.classList.toggle('from-sub-agent', !!fromSubagent); - + render(uri: URI): void { this.progressStore.clear(); this._uri = uri; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts new file mode 100644 index 00000000000..ed335fca9d7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import { $ } from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { rcut } from '../../../../../../base/common/strings.js'; +import { localize } from '../../../../../../nls.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IRunSubagentToolInputParams, RunSubagentTool } from '../../../common/tools/builtinTools/runSubagentTool.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; +import './media/chatSubagentContent.css'; + +const MAX_TITLE_LENGTH = 100; + +/** + * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not + * trying to refactor to share code. Both could probably be simplified when stable. + */ +export class ChatSubagentContentPart extends ChatCollapsibleContentPart implements IChatContentPart { + private wrapper!: HTMLElement; + private isActive: boolean = true; + private hasToolItems: boolean = false; + private readonly isInitiallyComplete: boolean; + private promptContainer: HTMLElement | undefined; + private resultContainer: HTMLElement | undefined; + private lastItemWrapper: HTMLElement | undefined; + private readonly layoutScheduler: RunOnceScheduler; + private description: string; + private agentName: string | undefined; + private prompt: string | undefined; + + /** + * Extracts subagent info (description, agentName, prompt) from a tool invocation. + */ + private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined } { + const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); + + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + // Check toolSpecificData first (works for both live and serialized) + if (toolInvocation.toolSpecificData?.kind === 'subagent') { + return { + description: toolInvocation.toolSpecificData.description ?? defaultDescription, + agentName: toolInvocation.toolSpecificData.agentName, + prompt: toolInvocation.toolSpecificData.prompt, + }; + } + + // Fallback to parameters for live invocations + if (toolInvocation.kind === 'toolInvocation') { + const state = toolInvocation.state.get(); + const params = state.type !== IChatToolInvocation.StateKind.Streaming ? + state.parameters as IRunSubagentToolInputParams | undefined + : undefined; + return { + description: params?.description ?? defaultDescription, + agentName: params?.agentName, + prompt: params?.prompt, + }; + } + + return { description: defaultDescription, agentName: undefined, prompt: undefined }; + } + + constructor( + public readonly subAgentInvocationId: string, + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + private readonly context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, + ) { + // Extract description, agentName, and prompt from toolInvocation + const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + + // Build title: "AgentName: description" or "Subagent: description" + const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); + const initialTitle = `${prefix}: ${description}`; + super(initialTitle, context, undefined, hoverService); + + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.isInitiallyComplete = this.element.isComplete; + + const node = this.domNode; + node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); + node.tabIndex = 0; + + // Hide initially until there are tool calls + node.style.display = 'none'; + + if (this._collapseButton && !this.element.isComplete) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } + + this._register(autorun(r => { + this.expanded.read(r); + if (this._collapseButton && this.wrapper) { + if (this.wrapper.classList.contains('chat-thinking-streaming') && !this.element.isComplete && this.isActive) { + this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + } else { + this._collapseButton.icon = Codicon.check; + } + } + })); + + // Start collapsed - fixed scrolling mode shows limited height when collapsed + this.setExpanded(false); + + // Scheduler for coalescing layout operations + this.layoutScheduler = this._register(new RunOnceScheduler(() => this.performLayout(), 0)); + + // Render the prompt section at the start if available (must be after wrapper is initialized) + this.renderPromptSection(); + + // Watch for completion and render result + this.watchToolCompletion(toolInvocation); + } + + protected override initContent(): HTMLElement { + const baseClasses = '.chat-used-context-list.chat-thinking-collapsible'; + const classes = this.isInitiallyComplete + ? baseClasses + : `${baseClasses}.chat-thinking-streaming`; + this.wrapper = $(classes); + return this.wrapper; + } + + /** + * Renders the prompt as a collapsible section at the start of the content. + */ + private renderPromptSection(): void { + if (!this.prompt || this.promptContainer) { + return; + } + + // Split into first line and rest + const lines = this.prompt.split('\n'); + const rawFirstLine = lines[0] || localize('chat.subagent.prompt', 'Prompt'); + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : (restOfLines || this.prompt); + + // Create collapsible prompt part with comment icon + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + collapsiblePart.icon = Codicon.comment; + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.promptContainer = collapsiblePart.domNode; + // Insert at the beginning of the wrapper + if (this.wrapper.firstChild) { + this.wrapper.insertBefore(this.promptContainer, this.wrapper.firstChild); + } else { + dom.append(this.wrapper, this.promptContainer); + } + } + + public getIsActive(): boolean { + return this.isActive; + } + + public markAsInactive(): void { + this.isActive = false; + this.wrapper.classList.remove('chat-thinking-streaming'); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + this.finalizeTitle(); + // Collapse when done + this.setExpanded(false); + this._onDidChangeHeight.fire(); + } + + public finalizeTitle(): void { + this.updateTitle(); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.check; + } + } + + private updateTitle(): void { + if (this._collapseButton) { + const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); + const finalLabel = `${prefix}: ${this.description}`; + this._collapseButton.label = finalLabel; + } + } + + /** + * Watches the tool invocation for completion and renders the result. + * Handles both live and serialized invocations. + */ + private watchToolCompletion(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolId !== RunSubagentTool.Id) { + return; + } + + if (toolInvocation.kind === 'toolInvocation') { + // Watch for completion and render the result + let wasStreaming = toolInvocation.state.get().type === IChatToolInvocation.StateKind.Streaming; + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + wasStreaming = false; + // Extract text from result + const textParts = (state.contentForModel || []) + .filter((part): part is { kind: 'text'; value: string } => part.kind === 'text') + .map(part => part.value); + + if (textParts.length > 0) { + this.renderResultText(textParts.join('\n')); + } + + // 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); + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.renderPromptSection(); + this.updateTitle(); + } + })); + } else if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.result) { + // Render the persisted result for serialized invocations + this.renderResultText(toolInvocation.toolSpecificData.result); + // Already complete, mark as inactive + this.markAsInactive(); + } + } + + public renderResultText(resultText: string): void { + if (this.resultContainer || !resultText) { + return; // Already rendered or no content + } + + // Split into first line and rest + const lines = resultText.split('\n'); + const rawFirstLine = lines[0] || ''; + const restOfLines = lines.slice(1).join('\n').trim(); + + // Limit first line length, moving overflow to content + const titleContent = rcut(rawFirstLine, MAX_TITLE_LENGTH); + const wasTruncated = rawFirstLine.length > MAX_TITLE_LENGTH; + const title = wasTruncated ? titleContent + '…' : titleContent; + const titleRemainder = rawFirstLine.length > titleContent.length ? rawFirstLine.slice(titleContent.length).trim() : ''; + const content = titleRemainder + ? (titleRemainder + (restOfLines ? '\n' + restOfLines : '')) + : restOfLines; + + // Create collapsible result part + const collapsiblePart = this._register(this.instantiationService.createInstance( + ChatCollapsibleMarkdownContentPart, + title, + content, + this.context, + this.chatContentMarkdownRenderer + )); + this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + this.resultContainer = collapsiblePart.domNode; + dom.append(this.wrapper, this.resultContainer); + + // Show the container if it was hidden + if (this.domNode.style.display === 'none') { + this.domNode.style.display = ''; + } + + this._onDidChangeHeight.fire(); + } + + public appendItem(content: HTMLElement, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (!content.hasChildNodes() || content.textContent?.trim() === '') { + return; + } + + // Show the container when first tool item is added + if (!this.hasToolItems) { + this.hasToolItems = true; + this.domNode.style.display = ''; + } + + // Wrap with icon like thinking parts do, but skip icon for tools needing confirmation + const itemWrapper = $('.chat-thinking-tool-wrapper'); + let needsConfirmation = false; + if (toolInvocation.kind === 'toolInvocation' && toolInvocation.state) { + const state = toolInvocation.state.get(); + needsConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; + } + + if (!needsConfirmation) { + const icon = getToolInvocationIcon(toolInvocation.toolId); + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + } + itemWrapper.appendChild(content); + + // Insert before result container if it exists, otherwise append + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + this.lastItemWrapper = itemWrapper; + + // Watch for tool completion to update height when label changes + if (toolInvocation.kind === 'toolInvocation') { + this._register(autorun(r => { + const state = toolInvocation.state.read(r); + if (state.type === IChatToolInvocation.StateKind.Completed) { + this._onDidChangeHeight.fire(); + } + })); + } + + // Schedule layout to measure last item and scroll + this.layoutScheduler.schedule(); + } + + private performLayout(): void { + // Measure last item height once after layout, set CSS variable for collapsed max-height + if (this.lastItemWrapper) { + const itemHeight = this.lastItemWrapper.offsetHeight; + const height = itemHeight + 4; + if (height > 0) { + this.wrapper.style.setProperty('--chat-subagent-last-item-height', `${height}px`); + } + } + + // Auto-scroll to bottom only when actively streaming (not for completed responses) + if (this.isActive && !this.isInitiallyComplete) { + const scrollHeight = this.wrapper.scrollHeight; + this.wrapper.scrollTop = scrollHeight; + } + + this._onDidChangeHeight.fire(); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || other.toolId === RunSubagentTool.Id)) { + // For runSubagent tool, use toolCallId as the effective ID + const otherEffectiveId = other.toolId === RunSubagentTool.Id ? other.toolCallId : other.subAgentInvocationId; + // If both have IDs, they must match + if (this.subAgentInvocationId && otherEffectiveId) { + return this.subAgentInvocationId === otherEffectiveId; + } + // Fallback for tools without IDs - group if this part has no ID and tool has no ID + return !this.subAgentInvocationId && !otherEffectiveId; + } + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index d26cbe3869f..14576106a78 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -35,7 +35,7 @@ function extractTextFromPart(content: IChatThinkingPart): string { return raw.trim(); } -function getToolInvocationIcon(toolId: string): ThemeIcon { +export function getToolInvocationIcon(toolId: string): ThemeIcon { const lowerToolId = toolId.toLowerCase(); if ( @@ -69,7 +69,7 @@ function getToolInvocationIcon(toolId: string): ThemeIcon { return Codicon.tools; } -function createThinkingIcon(icon: ThemeIcon): HTMLElement { +export function createThinkingIcon(icon: ThemeIcon): HTMLElement { const iconElement = $('span.chat-thinking-icon'); iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); return iconElement; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index eac8d7063fb..8b38d206e06 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -84,7 +84,6 @@ export interface ICodeBlockData { readonly languageId: string; readonly codemapperUri?: URI; - readonly fromSubagent?: boolean; readonly vulns?: readonly IMarkdownVulnerability[]; readonly range?: Range; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css new file mode 100644 index 00000000000..417be791784 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Subagent-specific styles */ +.interactive-session .interactive-response .value .chat-thinking-fixed-mode.chat-subagent-part { + /* Collapsed + streaming: show only the last item with max-height */ + &.chat-used-context-collapsed .chat-used-context-list.chat-thinking-collapsible.chat-thinking-streaming { + max-height: var(--chat-subagent-last-item-height, 200px); + overflow: hidden; + display: block; + } + + /* Expanded: show all content, no max-height, no scrolling */ + .chat-used-context-list.chat-thinking-collapsible { + max-height: none; + overflow: visible; + } +} + +/* Subagent result collapsible section */ +.chat-subagent-result { + margin-top: 4px; + padding: 4px 8px; + + .chat-used-context-label { + cursor: pointer; + + .monaco-button { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s); + } + } + + .chat-subagent-result-content { + padding: 4px 8px 4px 20px; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + + p { + margin: 0; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 7947d601b76..c541a208ec7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -95,11 +95,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS ) { super(toolInvocation); - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } - const state = toolInvocation.state.get(); if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { throw new Error('Confirmation messages are missing'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index dffa3138a9b..150d5bd1beb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -78,11 +78,6 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { partType: 'chatToolConfirmation', subtitle: typeof toolInvocation.originMessage === 'string' ? toolInvocation.originMessage : toolInvocation.originMessage?.value, }); - - // Tag for sub-agent styling - if (toolInvocation.fromSubAgent) { - context.container.classList.add('from-sub-agent'); - } } protected override additionalPrimaryActions() { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 553a1532a30..5e079e24834 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -68,9 +68,6 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa super(); this.domNode = dom.$('.chat-tool-invocation-part'); - if (toolInvocation.fromSubAgent) { - this.domNode.classList.add('from-sub-agent'); - } if (toolInvocation.presentation === 'hidden') { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c59b8c329a2..ef2be62a646 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -88,12 +88,14 @@ import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, Coll import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart } from './chatContentParts/chatThinkingContentPart.js'; +import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; import { ChatTreeContentPart, TreePool } from './chatContentParts/chatTreeContentPart.js'; import { ChatToolInvocationPart } from './chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { ChatMarkdownDecorationsRenderer } from './chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; +import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; const $ = dom.$; @@ -743,6 +745,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, subAgentInvocationId?: string): ChatSubagentContentPart | undefined { + if (!renderedParts || renderedParts.length === 0) { + return undefined; + } + + // Search backwards for the most recent subagent part + for (let i = renderedParts.length - 1; i >= 0; i--) { + const part = renderedParts[i]; + if (part instanceof ChatSubagentContentPart) { + // If looking for a specific ID, return the part with that ID regardless of active state + if (subAgentInvocationId && part.subAgentInvocationId === subAgentInvocationId) { + return part; + } + // If no ID specified, only return active parts + if (!subAgentInvocationId && part.getIsActive()) { + return part; + } + } + } + + return undefined; + } + + private finalizeAllSubagentParts(templateData: IChatListItemTemplate): void { + if (!templateData.renderedParts) { + return; + } + + // Finalize all active subagent parts (there can be multiple parallel subagents) + for (const part of templateData.renderedParts) { + if (part instanceof ChatSubagentContentPart && part.getIsActive()) { + part.markAsInactive(); + } + } + } + + private handleSubagentToolGrouping(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, part: ChatToolInvocationPart, subagentId: string, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatSubagentContentPart { + // Finalize any active thinking part since subagent tools have their own grouping + this.finalizeCurrentThinkingPart(context, templateData); + + const lastSubagent = this.getSubagentPart(templateData.renderedParts, subagentId); + if (lastSubagent) { + // Append to existing subagent part with matching ID + // But skip the runSubagent tool itself - we only want child tools + if (toolInvocation.toolId !== RunSubagentTool.Id) { + lastSubagent.appendItem(part.domNode!, toolInvocation); + } + lastSubagent.addDisposable(part); + return lastSubagent; + } + + // Create a new subagent part - it will extract description/agentName/prompt and watch for completion + const subagentPart = this.instantiationService.createInstance(ChatSubagentContentPart, subagentId, toolInvocation, context, this.chatContentMarkdownRenderer); + // Don't append the runSubagent tool itself - its description is already shown in the title + // Only append child tools (those with subAgentInvocationId) + if (toolInvocation.toolId !== RunSubagentTool.Id) { + subagentPart.appendItem(part.domNode!, toolInvocation); + } + subagentPart.addDisposable(part); + subagentPart.addDisposable(subagentPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + return subagentPart; + } + private finalizeCurrentThinkingPart(context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): void { const lastThinking = this.getLastThinkingPart(templateData.renderedParts); if (!lastThinking) { @@ -1361,6 +1434,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 122fc37518e..8f91967ab7f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -563,15 +563,6 @@ } } -.interactive-item-container .value .from-sub-agent { - &.chat-tool-invocation-part, - &.chat-confirmation-widget, - &.chat-terminal-confirmation-widget, - &.chat-codeblock-pill-widget { - margin-left: 18px; - } -} - .interactive-item-container .value > .rendered-markdown li > p { margin: 0; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 6b3b040c4a6..1ce3f95cd45 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -151,7 +151,6 @@ export interface IChatMarkdownContent { kind: 'markdownContent'; content: IMarkdownString; inlineReferences?: Record; - fromSubagent?: boolean; } export interface IChatTreeData { @@ -449,14 +448,14 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; - readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; readonly source: ToolDataSource; readonly toolId: string; readonly toolCallId: string; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; readonly state: IObservable; generatedTitle?: string; @@ -707,7 +706,7 @@ export interface IToolResultOutputDetailsSerialized { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; @@ -718,7 +717,7 @@ export interface IChatToolInvocationSerialized { toolCallId: string; toolId: string; source: ToolDataSource; - readonly fromSubAgent?: boolean; + readonly subAgentInvocationId?: string; generatedTitle?: string; kind: 'toolInvocationSerialized'; } @@ -737,6 +736,14 @@ export interface IChatPullRequestContent { kind: 'pullRequest'; } +export interface IChatSubagentToolInvocationData { + kind: 'subagent'; + description?: string; + agentName?: string; + prompt?: string; + result?: string; +} + export interface IChatTodoListContent { kind: 'todoList'; sessionId: string; diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index b5515039ffe..e16aeb1f0a1 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -7,14 +7,14 @@ import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; -import { ConfirmedReason, IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; +import { ConfirmedReason, IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult, ToolDataSource } from '../../tools/languageModelToolsService.js'; export interface IStreamingToolCallOptions { toolCallId: string; toolId: string; toolData: IToolData; - fromSubAgent?: boolean; + subagentInvocationId?: string; chatRequestId?: string; } @@ -28,12 +28,12 @@ export class ChatToolInvocation implements IChatToolInvocation { public presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; public source: ToolDataSource; - public readonly fromSubAgent: boolean | undefined; + public readonly subAgentInvocationId: string | undefined; public parameters: unknown; public generatedTitle?: string; public readonly chatRequestId?: string; - public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; private readonly _progress = observableValue<{ message?: string | IMarkdownString; progress: number | undefined }>(this, { progress: 0 }); private readonly _state: ISettableObservable; @@ -51,14 +51,14 @@ export class ChatToolInvocation implements IChatToolInvocation { * Use this when the tool call is beginning to stream partial input from the LM. */ public static createStreaming(options: IStreamingToolCallOptions): ChatToolInvocation { - return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.fromSubAgent, undefined, true, options.chatRequestId); + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, undefined, true, options.chatRequestId); } constructor( preparedInvocation: IPreparedToolInvocation | undefined, toolData: IToolData, public readonly toolCallId: string, - fromSubAgent: boolean | undefined, + subAgentInvocationId: string | undefined, parameters: unknown, isStreaming: boolean = false, chatRequestId?: string @@ -73,7 +73,7 @@ export class ChatToolInvocation implements IChatToolInvocation { this.toolSpecificData = preparedInvocation?.toolSpecificData; this.toolId = toolData.id; this.source = toolData.source; - this.fromSubAgent = fromSubAgent; + this.subAgentInvocationId = subAgentInvocationId; this.parameters = parameters; this.chatRequestId = chatRequestId; @@ -278,7 +278,7 @@ export class ChatToolInvocation implements IChatToolInvocation { toolSpecificData: this.toolSpecificData, toolCallId: this.toolCallId, toolId: this.toolId, - fromSubAgent: this.fromSubAgent, + subAgentInvocationId: this.subAgentInvocationId, generatedTitle: this.generatedTitle, }; } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 16d66aa1982..36f2fb547bc 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -149,7 +149,10 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; - isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + */ + subAgentInvocationId?: string; } 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 d5329cd630d..6f9295fd725 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -40,8 +40,6 @@ import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomati import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; -export const RunSubagentToolId = 'runSubagent'; - const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. - Agents do not run async or in the background, you will wait for the agent\'s result. @@ -50,7 +48,7 @@ const BaseModelDescription = `Launch a new agent to handle complex, multi-step t - The agent's outputs should generally be trusted - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent`; -interface IRunSubagentToolInputParams { +export interface IRunSubagentToolInputParams { prompt: string; description: string; agentName?: string; @@ -58,6 +56,8 @@ interface IRunSubagentToolInputParams { export class RunSubagentTool extends Disposable implements IToolImpl { + static readonly Id = 'runSubagent'; + readonly onDidUpdateToolData: Event; constructor( @@ -100,7 +100,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modelDescription += `\n- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`; } const runSubagentToolData: IToolData = { - id: RunSubagentToolId, + id: RunSubagentTool.Id, toolReferenceName: VSCodeToolReference.runSubagent, icon: ThemeIcon.fromId(Codicon.organization.id), displayName: localize('tool.runSubagent.displayName', 'Run Subagent'), @@ -194,7 +194,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { if (part.kind === 'codeblockUri' && !inEdit) { inEdit = true; - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n') }); } model.acceptResponseProgress(request, part); @@ -204,7 +204,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } else if (part.kind === 'markdownContent') { if (inEdit) { - model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n'), fromSubagent: true }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); inEdit = false; } @@ -215,7 +215,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { }; if (modeTools) { - modeTools[RunSubagentToolId] = false; + modeTools[RunSubagentTool.Id] = false; modeTools[ManageTodoListToolToolId] = false; } @@ -229,7 +229,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - isSubagent: true, + subAgentInvocationId: invocation.chatStreamToolCallId, userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, @@ -249,7 +249,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } - return createToolSimpleTextResult(markdownParts.join('') || 'Agent completed with no output'); + const resultText = markdownParts.join('') || 'Agent completed with no output'; + + // Store result in toolSpecificData for serialization + if (invocation.toolSpecificData?.kind === 'subagent') { + invocation.toolSpecificData.result = resultText; + } + + return createToolSimpleTextResult(resultText); } catch (error) { const errorMessage = `Error invoking subagent: ${error instanceof Error ? error.message : 'Unknown error'}`; @@ -263,6 +270,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return { invocationMessage: args.description, + toolSpecificData: { + kind: 'subagent', + description: args.description, + agentName: args.agentName, + prompt: args.prompt, + }, }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index b2c10b4d433..d8f88d8d802 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -24,7 +24,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { IChatExtensionsContent, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { LanguageModelPartAudience } from '../languageModels.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -139,8 +139,8 @@ export interface IToolInvocation { /** * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups */ - fromSubAgent?: boolean; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + subAgentInvocationId?: string; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; modelId?: string; userSelectedTools?: UserSelectedTools; } @@ -308,7 +308,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: ToolInvocationPresentation; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; } export interface IToolImpl { @@ -376,7 +376,7 @@ export interface IBeginToolCallOptions { toolId: string; chatRequestId?: string; sessionResource?: URI; - fromSubAgent?: boolean; + subagentInvocationId?: string; } export const ILanguageModelToolsService = createDecorator('ILanguageModelToolsService'); diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 01dcc338f80..83235b8fea7 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -107,7 +107,7 @@ declare module 'vscode' { isConfirmed?: boolean; isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData; - fromSubAgent?: boolean; + subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, toolCallId: string, isError?: boolean); @@ -359,7 +359,7 @@ declare module 'vscode' { * @param toolName The name of the tool being invoked. * @param streamData Optional initial streaming data with partial arguments. */ - beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): void; + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData & { subagentInvocationId?: string }): void; /** * Update the streaming data for a tool invocation that was started with `beginToolInvocation`. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 6b6c670a527..39861c8e498 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -93,7 +93,11 @@ declare module 'vscode' { */ readonly editedFileEvents?: ChatRequestEditedFileEvent[]; - readonly isSubagent?: boolean; + /** + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. + * Pass this to tool invocations when calling tools from within a subagent context. + */ + readonly subAgentInvocationId?: string; } export enum ChatRequestEditedFileEventKind { @@ -234,9 +238,9 @@ declare module 'vscode' { chatInteractionId?: string; terminalCommand?: string; /** - * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups + * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ - fromSubAgent?: boolean; + subAgentInvocationId?: string; } export interface LanguageModelToolInvocationPrepareOptions {