Skip to content

Commit 520359d

Browse files
jackfranklinDevtools-frontend LUCI CQ
authored andcommitted
RPP: store pure function calls as facts
This CL implements some basic caching for the pure functions that we expose to the AI. This avoids them continually recalling the same functions which is inefficient and also a poor user experience to see the AI continually repeat the exact same calls. The data is cached based on the insight, ensuring that if the insight changes, we invalidate the cache. We could do this better by caching based on the bounds of the insight (because if the bounds are the same, we don't need to recall the function, regardless of which insight it is) but that will be harder for the AI to understand, whereas this works reliably based on my testing. Bug: 408172181 Change-Id: I243becee05140a2c4ca148cb3aed25936285f29d Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6439198 Reviewed-by: Alex Rudenko <[email protected]> Commit-Queue: Jack Franklin <[email protected]> Auto-Submit: Jack Franklin <[email protected]> Commit-Queue: Alex Rudenko <[email protected]>
1 parent e7a43fb commit 520359d

File tree

2 files changed

+173
-1
lines changed

2 files changed

+173
-1
lines changed

front_end/models/ai_assistance/agents/PerformanceInsightsAgent.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,5 +312,131 @@ Help me understand?`;
312312
canceled: false
313313
});
314314
});
315+
316+
it('caches getNetworkActivitySummary calls and passes them to future requests as facts', async function() {
317+
const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
318+
assert.isOk(insights);
319+
const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
320+
const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
321+
const agent = new PerformanceInsightsAgent({
322+
aidaClient: mockAidaClient([
323+
[{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}]
324+
])
325+
});
326+
const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
327+
const context = new InsightContext(activeInsight);
328+
329+
// Make the first query to trigger the getNetworkActivitySummary function
330+
const responses = await Array.fromAsync(agent.run('test', {selected: context}));
331+
const action = responses.find(response => response.type === ResponseType.ACTION);
332+
assert.exists(action);
333+
assert.strictEqual(action.code, 'getNetworkActivitySummary()');
334+
335+
// Trigger another request so that the agent populates the facts.
336+
await Array.fromAsync(agent.run('test 2', {selected: context}));
337+
338+
assert.strictEqual(agent.currentFacts().size, 1);
339+
const networkSummaryFact = Array.from(agent.currentFacts()).at(0);
340+
assert.exists(networkSummaryFact);
341+
342+
const expectedRequestUrls = [
343+
'https://chromedevtools.github.io/performance-stories/lcp-large-image/index.html',
344+
'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@1,800',
345+
'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css',
346+
'https://via.placeholder.com/50.jpg', 'https://via.placeholder.com/2000.jpg'
347+
];
348+
// Ensure that each URL was in the fact as a way to validate the fact is accurate.
349+
assert.isTrue(expectedRequestUrls.every(url => {
350+
return networkSummaryFact.text.includes(url);
351+
}));
352+
353+
// Now we make one more request; we do this to ensure that we don't add the same fact again.
354+
await Array.fromAsync(agent.run('test 3', {selected: context}));
355+
356+
assert.strictEqual(agent.currentFacts().size, 1);
357+
});
358+
359+
it('caches getMainThreadActivity calls and passes them to future requests as facts', async function() {
360+
const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
361+
assert.isOk(insights);
362+
const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
363+
const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
364+
const agent = new PerformanceInsightsAgent({
365+
aidaClient: mockAidaClient(
366+
[[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}], [{explanation: 'done'}]])
367+
});
368+
const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
369+
const context = new InsightContext(activeInsight);
370+
371+
// Make the first query to trigger the getMainThreadActivity function
372+
const responses = await Array.fromAsync(agent.run('test', {selected: context}));
373+
const action = responses.find(response => response.type === ResponseType.ACTION);
374+
assert.exists(action);
375+
assert.strictEqual(action.code, 'getMainThreadActivity()');
376+
377+
// Trigger another request so that the agent populates the facts.
378+
await Array.fromAsync(agent.run('test 2', {selected: context}));
379+
380+
assert.strictEqual(agent.currentFacts().size, 1);
381+
const mainThreadActivityFact = Array.from(agent.currentFacts()).at(0);
382+
assert.exists(mainThreadActivityFact);
383+
384+
const expectedTree = TimelineUtils.InsightAIContext.AIQueries.mainThreadActivity(lcpPhases, parsedTrace);
385+
assert.isOk(expectedTree);
386+
assert.include(mainThreadActivityFact.text, expectedTree.serialize());
387+
388+
// Now we make one more request; we do this to ensure that we don't add the same fact again.
389+
await Array.fromAsync(agent.run('test 3', {selected: context}));
390+
391+
assert.strictEqual(agent.currentFacts().size, 1);
392+
});
393+
394+
it('will not send facts from a previous insight if the context changes', async function() {
395+
const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
396+
assert.isOk(insights);
397+
const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
398+
const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
399+
const renderBlocking = getInsightOrError('RenderBlocking', insights, firstNav);
400+
const agent = new PerformanceInsightsAgent({
401+
aidaClient: mockAidaClient([
402+
[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}],
403+
])
404+
});
405+
const lcpPhasesActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
406+
const lcpContext = new InsightContext(lcpPhasesActiveInsight);
407+
const renderBlockingActiveInsight = new TimelineUtils.InsightAIContext.ActiveInsight(renderBlocking, parsedTrace);
408+
const renderBlockingContext = new InsightContext(renderBlockingActiveInsight);
409+
410+
// Populate the function calls for the LCP Context
411+
await Array.fromAsync(agent.run('test 1 LCP', {selected: lcpContext}));
412+
await Array.fromAsync(agent.run('test 2 LCP', {selected: lcpContext}));
413+
assert.strictEqual(agent.currentFacts().size, 1);
414+
// Now change the context and send a request.
415+
await Array.fromAsync(agent.run('test 1 RenderBlocking', {selected: renderBlockingContext}));
416+
// Because the context changed, we should now not have any facts.
417+
assert.strictEqual(agent.currentFacts().size, 0);
418+
});
419+
420+
it('will send multiple facts', async function() {
421+
const {parsedTrace, insights} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
422+
assert.isOk(insights);
423+
const [firstNav] = parsedTrace.Meta.mainFrameNavigations;
424+
const lcpPhases = getInsightOrError('LCPPhases', insights, firstNav);
425+
const agent = new PerformanceInsightsAgent({
426+
aidaClient: mockAidaClient([
427+
[{explanation: '', functionCalls: [{name: 'getMainThreadActivity', args: {}}]}],
428+
[{explanation: '', functionCalls: [{name: 'getNetworkActivitySummary', args: {}}]}], [{explanation: 'done'}]
429+
])
430+
});
431+
const activeInsight = new TimelineUtils.InsightAIContext.ActiveInsight(lcpPhases, parsedTrace);
432+
const context = new InsightContext(activeInsight);
433+
// First query to populate the function calls
434+
await Array.fromAsync(agent.run('test 1', {selected: context}));
435+
// Second query should have two facts
436+
await Array.fromAsync(agent.run('test 2', {selected: context}));
437+
assert.deepEqual(Array.from(agent.currentFacts(), fact => {
438+
return fact.metadata.source;
439+
}), ['getMainThreadActivity()', 'getNetworkActivitySummary()']);
440+
});
315441
});
316442
});

front_end/models/ai_assistance/agents/PerformanceInsightsAgent.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,23 @@ export class PerformanceInsightsAgent extends AiAgent<TimelineUtils.InsightAICon
196196

197197
#lastContextForEnhancedQuery: ConversationContext<TimelineUtils.InsightAIContext.ActiveInsight>|undefined;
198198

199+
/**
200+
* Store results (as facts) for the functions that are pure and return the
201+
* same data for the same insight.
202+
* This fact is then passed into the request on all future
203+
* queries for the conversation. This means that the LLM is far less likely to
204+
* call the function again, because we have provided the same data as a
205+
* fact. We cache based on the active insight to ensure that if the user
206+
* changes which insight they are focusing we will call the function again.
207+
* It's important that we store it as a Fact in the cache, because the AI
208+
* Agent stores facts in a set, and we need to pass the same object through to
209+
* make sure it isn't mistakenly duplicated in the request.
210+
*/
211+
#functionCallCache = new Map<TimelineUtils.InsightAIContext.ActiveInsight, {
212+
getNetworkActivitySummary?: Host.AidaClient.RequestFact,
213+
getMainThreadActivity?: Host.AidaClient.RequestFact,
214+
}>();
215+
199216
override async *
200217
handleContextDetails(activeContext: ConversationContext<TimelineUtils.InsightAIContext.ActiveInsight>|null):
201218
AsyncGenerator<ContextResponse, void, void> {
@@ -263,6 +280,15 @@ export class PerformanceInsightsAgent extends AiAgent<TimelineUtils.InsightAICon
263280
);
264281
const formatted =
265282
requests.map(r => TraceEventFormatter.networkRequest(r, activeInsight.parsedTrace, {verbose: false}));
283+
const summaryFact: Host.AidaClient.RequestFact = {
284+
text:
285+
`This is the network summary for this insight. You can use this and not call getNetworkActivitySummary again:\n${
286+
formatted.join('\n')}`,
287+
metadata: {source: 'getNetworkActivitySummary()'}
288+
};
289+
const cacheForInsight = this.#functionCallCache.get(activeInsight) ?? {};
290+
cacheForInsight.getNetworkActivitySummary = summaryFact;
291+
this.#functionCallCache.set(activeInsight, cacheForInsight);
266292
return {result: {requests: formatted}};
267293
},
268294
});
@@ -349,7 +375,18 @@ The fields are:
349375
if (!tree) {
350376
return {error: 'No main thread activity found'};
351377
}
352-
return {result: {activity: tree.serialize()}};
378+
const activity = tree.serialize();
379+
const activityFact: Host.AidaClient.RequestFact = {
380+
text:
381+
`This is the main thread activity for this insight. You can use this and not call getMainThreadActivity again:\n${
382+
activity}`,
383+
metadata: {source: 'getMainThreadActivity()'},
384+
};
385+
const cacheForInsight = this.#functionCallCache.get(activeInsight) ?? {};
386+
cacheForInsight.getMainThreadActivity = activityFact;
387+
this.#functionCallCache.set(activeInsight, cacheForInsight);
388+
389+
return {result: {activity}};
353390
},
354391

355392
});
@@ -398,6 +435,15 @@ The fields are:
398435
}): AsyncGenerator<ResponseData, void, void> {
399436
this.#insight = options.selected ?? undefined;
400437

438+
// Clear any previous facts in case the user changed the active context.
439+
this.clearFacts();
440+
const cachedFunctionCalls = this.#insight ? this.#functionCallCache.get(this.#insight.getItem()) : null;
441+
if (cachedFunctionCalls) {
442+
for (const fact of Object.values(cachedFunctionCalls)) {
443+
this.addFact(fact);
444+
}
445+
}
446+
401447
return yield* super.run(initialQuery, options);
402448
}
403449
}

0 commit comments

Comments
 (0)