Skip to content

Commit f72b44b

Browse files
OrKoNDevtools-frontend LUCI CQ
authored andcommitted
[AI Assistance] Patch agent to find files
Basic version of an agent for the change summary view that tells which files need to be updated. Bug: 393267670 Change-Id: I5d99269f697599603afc8764e319fa4e1337bc76 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6249519 Commit-Queue: Alex Rudenko <[email protected]> Reviewed-by: Ergün Erdoğmuş <[email protected]>
1 parent 7a8c20e commit f72b44b

File tree

8 files changed

+198
-161
lines changed

8 files changed

+198
-161
lines changed

front_end/panels/ai_assistance/AiAssistancePanel.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
NetworkAgent,
3737
RequestContext,
3838
} from './agents/NetworkAgent.js';
39+
import {PatchAgent, ProjectContext} from './agents/PatchAgent.js';
3940
import {CallTreeContext, PerformanceAgent} from './agents/PerformanceAgent.js';
4041
import {InsightContext, PerformanceInsightsAgent} from './agents/PerformanceInsightsAgent.js';
4142
import {NodeContext, StylingAgent, StylingAgentWithFunctionCalling} from './agents/StylingAgent.js';
@@ -249,6 +250,9 @@ export class AiAssistancePanel extends UI.Panel.Panel {
249250
accountImage?: string,
250251
accountFullName?: string,
251252
};
253+
#project?: Workspace.Workspace.Project;
254+
#patchSuggestion?: string;
255+
#patchSuggestionLoading?: boolean;
252256

253257
constructor(private view: View = defaultView, {aidaClient, aidaAvailability, syncInfo}: {
254258
aidaClient: Host.AidaClient.AidaClient,
@@ -271,6 +275,21 @@ export class AiAssistancePanel extends UI.Panel.Panel {
271275
};
272276

273277
this.#conversations = AiHistoryStorage.instance().getHistory().map(item => Conversation.fromSerialized(item));
278+
279+
if (isAiAssistancePatchingEnabled()) {
280+
// TODO: this is temporary code that should be replaced with workflow selection flow.
281+
// For now it picks the first Workspace project that is not Snippets.
282+
const projects =
283+
Workspace.Workspace.WorkspaceImpl.instance().projectsForType(Workspace.Workspace.projectTypes.FileSystem);
284+
this.#project = undefined;
285+
for (const project of projects) {
286+
if (project.displayName().trim() === '') {
287+
continue;
288+
}
289+
this.#project = project;
290+
break;
291+
}
292+
}
274293
}
275294

276295
#createToolbar(): void {
@@ -594,6 +613,12 @@ export class AiAssistancePanel extends UI.Panel.Panel {
594613
this.#updateAgentState(this.#currentAgent);
595614
};
596615

616+
#getChangeSummary(): string|undefined {
617+
return (isAiAssistancePatchingEnabled() && this.#currentAgent && !this.#currentConversation?.isReadOnly) ?
618+
this.#changeManager.formatChanges(this.#currentAgent.id) :
619+
undefined;
620+
}
621+
597622
async doUpdate(): Promise<void> {
598623
this.#updateToolbarState();
599624
this.view(
@@ -606,10 +631,9 @@ export class AiAssistancePanel extends UI.Panel.Panel {
606631
selectedContext: this.#selectedContext,
607632
agentType: this.#currentAgent?.type,
608633
isReadOnly: this.#currentConversation?.isReadOnly ?? false,
609-
changeSummary:
610-
(isAiAssistancePatchingEnabled() && this.#currentAgent && !this.#currentConversation?.isReadOnly) ?
611-
this.#changeManager.formatChanges(this.#currentAgent.id) :
612-
undefined,
634+
changeSummary: this.#getChangeSummary(),
635+
patchSuggestion: this.#patchSuggestion,
636+
patchSuggestionLoading: this.#patchSuggestionLoading,
613637
stripLinks: this.#currentAgent?.type === AgentType.PERFORMANCE,
614638
inspectElementToggled: this.#toggleSearchElementAction.toggled(),
615639
userInfo: this.#userInfo,
@@ -626,6 +650,7 @@ export class AiAssistancePanel extends UI.Panel.Panel {
626650
onCancelCrossOriginChat: this.#blockedByCrossOrigin && this.#previousSameOriginContext ?
627651
this.#handleCrossOriginChatCancellation.bind(this) :
628652
undefined,
653+
onStageToWorkspace: this.#onStageToWorkspace.bind(this)
629654
},
630655
this.#viewOutput, this.#contentContainer);
631656
}
@@ -883,6 +908,37 @@ export class AiAssistancePanel extends UI.Panel.Panel {
883908
UI.ARIAUtils.alert(lockedString(UIStringsNotTranslate.answerReady));
884909
}
885910

911+
async #onStageToWorkspace(): Promise<void> {
912+
if (!this.#project) {
913+
throw new Error('Project does not exist');
914+
}
915+
const agent = new PatchAgent({
916+
aidaClient: this.#aidaClient,
917+
serverSideLoggingEnabled: this.#serverSideLoggingEnabled,
918+
});
919+
this.#patchSuggestionLoading = true;
920+
void this.doUpdate();
921+
const prompt =
922+
`I have applied the following CSS changes to my page in Chrome DevTools, what are the files in my source code that I need to change to apply the same change?
923+
924+
\`\`\`css
925+
${this.#getChangeSummary()}
926+
\`\`\`
927+
928+
Try searching using the selectors and if nothing matches, try to find a semantically appropriate place to change.
929+
Output one filename per line and nothing else!
930+
`;
931+
932+
let response;
933+
for await (response of agent.run(prompt, {
934+
selected: new ProjectContext(this.#project),
935+
})) {
936+
}
937+
this.#patchSuggestion = response?.type === ResponseType.ANSWER ? response.text : 'Could not find files';
938+
this.#patchSuggestionLoading = false;
939+
void this.doUpdate();
940+
}
941+
886942
async *
887943
#saveResponsesToCurrentConversation(items: AsyncIterable<ResponseData, void, void>):
888944
AsyncGenerator<ResponseData, void, void> {

front_end/panels/ai_assistance/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ ts_library("unittests") {
9898
"agents/AiAgent.test.ts",
9999
"agents/FileAgent.test.ts",
100100
"agents/NetworkAgent.test.ts",
101+
"agents/PatchAgent.test.ts",
101102
"agents/PerformanceAgent.test.ts",
102103
"agents/StylingAgent.test.ts",
103104
"components/ChatView.test.ts",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2024 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as Platform from '../../../core/platform/platform.js';
6+
import {mockAidaClient, type MockAidaResponse} from '../../../testing/AiAssistanceHelpers.js';
7+
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
8+
import {createFileSystemUISourceCode} from '../../../testing/UISourceCodeHelpers.js';
9+
import {type ActionResponse, PatchAgent, ProjectContext, type ResponseData, ResponseType} from '../ai_assistance.js';
10+
11+
describeWithEnvironment('PatchAgent', () => {
12+
async function testAgent(mock: Array<[MockAidaResponse, ...MockAidaResponse[]]>): Promise<ResponseData[]> {
13+
const {project, uiSourceCode} = createFileSystemUISourceCode({
14+
url: Platform.DevToolsPath.urlString`file:///path/to/overrides/example.html`,
15+
mimeType: 'text/html',
16+
content: 'content',
17+
});
18+
19+
uiSourceCode.setWorkingCopyGetter(() => 'content working copy');
20+
21+
const agent = new PatchAgent({
22+
aidaClient: mockAidaClient(mock),
23+
});
24+
25+
return await Array.fromAsync(agent.run('test input', {selected: new ProjectContext(project)}));
26+
}
27+
28+
it('calls listFiles', async () => {
29+
const responses = await testAgent([
30+
[{explanation: '', functionCalls: [{name: 'listFiles', args: {}}]}], [{
31+
explanation: 'done',
32+
}]
33+
]);
34+
35+
const action = responses.find(response => response.type === ResponseType.ACTION);
36+
assert.exists(action);
37+
assert.deepEqual(action, {
38+
type: 'action' as ActionResponse['type'],
39+
output: '{"files":["//path/to/overrides/example.html"]}',
40+
canceled: false
41+
});
42+
});
43+
44+
it('calls searchInFiles', async () => {
45+
const responses = await testAgent([
46+
[{
47+
explanation: '',
48+
functionCalls: [{
49+
name: 'searchInFiles',
50+
args: {
51+
query: 'content',
52+
}
53+
}]
54+
}],
55+
[{
56+
explanation: 'done',
57+
}]
58+
]);
59+
60+
const action = responses.find(response => response.type === ResponseType.ACTION);
61+
assert.exists(action);
62+
assert.deepEqual(action, {
63+
type: 'action' as ActionResponse['type'],
64+
output:
65+
'{"matches":[{"filepath":"//path/to/overrides/example.html","lineNumber":0,"columnNumber":0,"matchLength":7}]}',
66+
canceled: false
67+
});
68+
});
69+
});

front_end/panels/ai_assistance/agents/PatchAgent.ts

Lines changed: 7 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,6 @@ import {
1717
type ResponseData,
1818
} from './AiAgent.js';
1919

20-
/* clang-format off */
21-
const preamble = `You are responsible for changing the source code on behalf of the user.
22-
The user query defines what changes are to be made.
23-
You have a number of functions to get information about source files in the project.
24-
Use those functions to fulfill the user query.
25-
26-
## Step-by-step instructions
27-
28-
- Think about what the user wants.
29-
- List all files in the project or search for relevant files.
30-
- Identify the files that are likely to be modified.
31-
- Retrieve the content of those files.
32-
- Rewrite the files according to the user query.
33-
34-
## General considerations
35-
36-
- Avoid requesting too many files.
37-
- Always prefer changing the true source files and not the build output.
38-
- The build output is usually in dist/, out/, build/ folders.
39-
- *CRITICAL* never make the same function call twice.
40-
- *CRITICAL* do not make any changes if not prompted.
41-
42-
Instead of using the writeFile function you can also produce the following diff format:
43-
44-
\`\`\`
45-
src/index.html
46-
<meta charset="utf-8">
47-
<title>Test</title>
48-
\`\`\`
49-
50-
First output the filename (example, src/index.html), then output the SEARCH block,
51-
followed by the REPLACE block.
52-
53-
`;
54-
/* clang-format on */
55-
5620
export class ProjectContext extends ConversationContext<Workspace.Workspace.Project> {
5721
readonly #project: Workspace.Workspace.Project;
5822

@@ -107,7 +71,7 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
10771
}
10872

10973
override readonly type = AgentType.PATCH;
110-
readonly preamble = preamble;
74+
readonly preamble = undefined;
11175
readonly clientFeature = Host.AidaClient.ClientFeature.CHROME_PATCH_AGENT;
11276

11377
get userTier(): string|undefined {
@@ -124,7 +88,7 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
12488
constructor(opts: BaseAgentOptions) {
12589
super(opts);
12690
this.declareFunction<Record<never, unknown>>('listFiles', {
127-
description: 'returns a list of all files in the project.',
91+
description: 'Returns a list of all files in the project.',
12892
parameters: {
12993
type: Host.AidaClient.ParametersTypes.OBJECT,
13094
description: '',
@@ -149,11 +113,11 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
149113

150114
this.declareFunction<{
151115
query: string,
152-
caseSensitive: boolean,
153-
isRegex: boolean,
116+
caseSensitive?: boolean,
117+
isRegex?: boolean,
154118
}>('searchInFiles', {
155119
description:
156-
'Searches for a query in all files in the project. For each match it returns the positions of matches.',
120+
'Searches for a text match in all files in the project. For each match it returns the positions of matches.',
157121
parameters: {
158122
type: Host.AidaClient.ParametersTypes.OBJECT,
159123
description: '',
@@ -186,7 +150,8 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
186150
const {map} = getFiles(project);
187151
const matches = [];
188152
for (const [filepath, file] of map.entries()) {
189-
const results = await project.searchInFileContent(file, params.query, params.caseSensitive, params.isRegex);
153+
const results = TextUtils.TextUtils.performSearchInContentData(
154+
file.workingCopyContentData(), params.query, params.caseSensitive ?? true, params.isRegex ?? false);
190155
for (const result of results) {
191156
matches.push({
192157
filepath,
@@ -203,118 +168,6 @@ export class PatchAgent extends AiAgent<Workspace.Workspace.Project> {
203168
};
204169
},
205170
});
206-
207-
this.declareFunction('changeFile', {
208-
description: 'returns a list of all files in the project.',
209-
parameters: {
210-
type: Host.AidaClient.ParametersTypes.OBJECT,
211-
description: '',
212-
nullable: true,
213-
properties: {
214-
filepath: {
215-
type: Host.AidaClient.ParametersTypes.STRING,
216-
description: 'A file path that identifies the file to get the content for',
217-
nullable: false,
218-
},
219-
},
220-
},
221-
handler: async () => {
222-
return {result: {}};
223-
},
224-
});
225-
226-
this.declareFunction<{filepath: string}>('readFile', {
227-
description: 'returns the complement content of a file',
228-
parameters: {
229-
type: Host.AidaClient.ParametersTypes.OBJECT,
230-
description: '',
231-
properties: {
232-
filepath: {
233-
type: Host.AidaClient.ParametersTypes.STRING,
234-
description: 'A file path that identifies the file to get the content for',
235-
nullable: false,
236-
},
237-
},
238-
},
239-
handler: async params => {
240-
if (!this.#project) {
241-
return {
242-
error: 'No project available',
243-
};
244-
}
245-
const project = this.#project.getItem();
246-
const {map} = getFiles(project);
247-
const uiSourceCode = map.get(params.filepath);
248-
if (!uiSourceCode) {
249-
return {
250-
error: `File ${params.filepath} not found`,
251-
};
252-
}
253-
// TODO: clearly define what types of files we handle.
254-
const content = await uiSourceCode.requestContentData();
255-
if (TextUtils.ContentData.ContentData.isError(content)) {
256-
return {
257-
error: content.error,
258-
};
259-
}
260-
if (!content.isTextContent) {
261-
return {
262-
error: 'Non-text files are not supported',
263-
};
264-
}
265-
266-
return {
267-
result: {
268-
content: content.text,
269-
}
270-
};
271-
},
272-
});
273-
274-
this.declareFunction<{filepath: string, content: string}>('writeFile', {
275-
description: '(over)writes the file with the provided content',
276-
parameters: {
277-
type: Host.AidaClient.ParametersTypes.OBJECT,
278-
description: '',
279-
properties: {
280-
filepath: {
281-
type: Host.AidaClient.ParametersTypes.STRING,
282-
description: 'A file path that identifies the file',
283-
nullable: false,
284-
},
285-
content: {
286-
type: Host.AidaClient.ParametersTypes.STRING,
287-
description: 'Full content of the file that will replace the current file content',
288-
nullable: false,
289-
},
290-
},
291-
},
292-
handler: async params => {
293-
if (!this.#project) {
294-
return {
295-
error: 'No project available',
296-
};
297-
}
298-
const project = this.#project.getItem();
299-
const {map} = getFiles(project);
300-
const uiSourceCode = map.get(params.filepath);
301-
if (!uiSourceCode) {
302-
return {
303-
error: `File ${params.filepath} not found`,
304-
};
305-
}
306-
const content = params.content;
307-
// TODO: we unescape some characters to restore the original
308-
// content but this should be fixed upstream.
309-
uiSourceCode.setContent(
310-
content.replaceAll('\\n', '\n').replaceAll('\\"', '"').replaceAll('\\\'', '\''),
311-
false,
312-
);
313-
return {
314-
result: null,
315-
};
316-
},
317-
});
318171
}
319172

320173
override async * run(initialQuery: string, options: {

0 commit comments

Comments
 (0)