cache llm generated thinking headers (#304619)

* cache llm generated titles as fallback for rerendering

* store llm generated headers so cli will not re-generate

* address some comments, scope to non-local sessions
This commit is contained in:
Justin Chen
2026-03-26 07:32:24 -07:00
committed by GitHub
parent 8de7f238b1
commit d65401ca67
3 changed files with 90 additions and 3 deletions

View File

@@ -116,7 +116,7 @@ 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; modelName: string | undefined } {
const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...');
const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent');
// Only parent subagent tools contain the full subagent info
if (!ChatSubagentContentPart.isParentSubagentTool(toolInvocation)) {

View File

@@ -34,9 +34,11 @@ import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js';
import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js';
import './media/chatThinkingContent.css';
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js';
import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js';
import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js';
import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js';
import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js';
@@ -140,6 +142,10 @@ interface ILazyThinkingItem {
type ILazyItem = ILazyToolItem | ILazyThinkingItem;
const THINKING_SCROLL_MAX_HEIGHT = 200;
const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache';
const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const TITLE_CACHE_MAX_ENTRIES = 1000;
const enum WorkingMessageCategory {
Thinking = 'thinking',
Terminal = 'terminal',
@@ -284,6 +290,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
@IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@IHoverService hoverService: IHoverService,
@IStorageService private readonly storageService: IStorageService,
) {
const initialText = extractTextFromPart(content);
const extractedTitle = extractTitleFromThinkingContent(initialText)
@@ -897,6 +904,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
return;
}
// Reuse any existing generated title from tool invocations or thinking parts.
const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle
?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle;
if (existingTitle) {
@@ -907,6 +915,27 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
return;
}
// Only check the persisted cache when re-rendering
// (all tool invocations are serialized), not during live streaming.
const allSerialized = this.toolInvocations.length > 0
&& this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized');
if (allSerialized) {
// Fallback: check the persisted title cache using the last tool call (non-local sessions only)
if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) {
const lastToolInvocation = this.toolInvocations[this.toolInvocations.length - 1];
if (lastToolInvocation) {
const cachedTitle = this.getCachedTitle(lastToolInvocation.toolCallId);
if (cachedTitle) {
this.currentTitle = cachedTitle;
this.content.generatedTitle = cachedTitle;
this.setGeneratedTitleOnAllParts(cachedTitle);
this.setFinalizedTitle(cachedTitle);
return;
}
}
}
}
// case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position
if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') {
// If singleItemInfo wasn't set (item was lazy/deferred), materialize it now
@@ -966,6 +995,55 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
}
}
private loadTitleCache(): Record<string, { title: string; storedAt: number }> {
return this.storageService.getObject<Record<string, { title: string; storedAt: number }>>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {};
}
private saveTitleCache(cache: Record<string, { title: string; storedAt: number }>): void {
if (Object.keys(cache).length === 0) {
this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE);
} else {
this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE);
}
}
private getTitleCacheKey(toolCallId: string): string {
return `${chatSessionResourceToId(this.element.sessionResource)}:${toolCallId}`;
}
private getCachedTitle(toolCallId: string): string | undefined {
const entry = this.loadTitleCache()[this.getTitleCacheKey(toolCallId)];
if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) {
return undefined;
}
return entry.title;
}
private setCachedTitle(toolCallId: string, title: string): void {
const cache = this.loadTitleCache();
const now = Date.now();
// Evict expired entries on write
for (const key of Object.keys(cache)) {
if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) {
delete cache[key];
}
}
cache[this.getTitleCacheKey(toolCallId)] = { title, storedAt: now };
// Cap size by dropping oldest entries
const keys = Object.keys(cache);
if (keys.length > TITLE_CACHE_MAX_ENTRIES) {
const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt);
for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) {
delete cache[sorted[i]];
}
}
this.saveTitleCache(cache);
}
private async generateTitleViaLLM(): Promise<void> {
const cts = new CancellationTokenSource();
const timeout = setTimeout(() => cts.cancel(), 5000);
@@ -1123,6 +1201,15 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
this.setFinalizedTitle(generatedTitle);
this.content.generatedTitle = generatedTitle;
this.setGeneratedTitleOnAllParts(generatedTitle);
// Persist to storage for non-local sessions only
if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) {
const lastTool = this.toolInvocations[this.toolInvocations.length - 1];
if (lastTool) {
this.setCachedTitle(lastTool.toolCallId, generatedTitle);
}
}
return;
}
} catch (error) {

View File

@@ -144,7 +144,7 @@ suite('ChatSubagentContentPart', () => {
prompt: 'Test prompt'
},
originMessage: undefined,
invocationMessage: options.invocationMessage ?? 'Running subagent...',
invocationMessage: options.invocationMessage ?? 'Running subagent',
pastTenseMessage: undefined,
source: ToolDataSource.Internal,
toolId: options.toolId ?? RunSubagentTool.Id,
@@ -180,7 +180,7 @@ suite('ChatSubagentContentPart', () => {
result: 'Test result text'
},
originMessage: undefined,
invocationMessage: 'Running subagent...',
invocationMessage: 'Running subagent',
pastTenseMessage: undefined,
resultDetails: undefined,
isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded },