Skip to content

Commit 248ba94

Browse files
authored
Ensure problemStatement sent to coding agent is always within the character limit (#7874)
* truncate everything * remove integration id * test:plus2 * user agent to both jobApi routes
1 parent 1f4d08b commit 248ba94

4 files changed

Lines changed: 194 additions & 24 deletions

File tree

src/github/copilotApi.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export class CopilotApi {
6868
return this.makeApiCallFullUrl(`${this.baseUrl}${api}`, init);
6969
}
7070

71+
private get userAgent(): string {
72+
const extensionVersion = vscode.extensions.getExtension('GitHub.vscode-pull-request-github')?.packageJSON.version ?? 'unknown';
73+
return `vscode-pull-request-github/${extensionVersion}`;
74+
}
75+
76+
7177
async postRemoteAgentJob(
7278
owner: string,
7379
name: string,
@@ -86,10 +92,10 @@ export class CopilotApi {
8692
const response = await this.makeApiCall(apiUrl, {
8793
method: 'POST',
8894
headers: {
89-
'Copilot-Integration-Id': 'copilot-developer-dev',
9095
'Authorization': `Bearer ${this.token}`,
9196
'Content-Type': 'application/json',
92-
'Accept': 'application/json'
97+
'Accept': 'application/json',
98+
'User-Agent': this.userAgent,
9399
},
94100
body: payloadJson
95101
});
@@ -269,10 +275,10 @@ export class CopilotApi {
269275
const response = await this.makeApiCall(`/agents/swe/v0/jobs/${owner}/${repo}/session/${sessionId}`, {
270276
method: 'GET',
271277
headers: {
272-
'Copilot-Integration-Id': 'copilot-developer-dev',
273278
'Authorization': `Bearer ${this.token}`,
274279
'Content-Type': 'application/json',
275-
'Accept': 'application/json'
280+
'Accept': 'application/json',
281+
'User-Agent': this.userAgent,
276282
}
277283
});
278284
if (!response.ok) {

src/github/copilotRemoteAgent.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH } from '../common/setti
1919
import { ITelemetry } from '../common/telemetry';
2020
import { toOpenPullRequestWebviewUri } from '../common/uri';
2121
import { copilotPRStatusToSessionStatus, IAPISessionLogs, ICopilotRemoteAgentCommandArgs, ICopilotRemoteAgentCommandResponse, OctokitCommon, RemoteAgentResult, RepoInfo } from './common';
22-
import { ChatSessionWithPR, CopilotApi, getCopilotApi, MAX_PROBLEM_STATEMENT_LENGTH, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi';
22+
import { ChatSessionWithPR, CopilotApi, getCopilotApi, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi';
2323
import { CodingAgentPRAndStatus, CopilotPRWatcher, CopilotStateModel } from './copilotPrWatcher';
2424
import { ChatSessionContentBuilder } from './copilotRemoteAgent/chatSessionContentBuilder';
2525
import { GitOperationsManager } from './copilotRemoteAgent/gitOperationsManager';
26+
import { extractTitle, formatBodyPlaceholder, truncatePrompt } from './copilotRemoteAgentUtils';
2627
import { CredentialStore } from './credentials';
2728
import { FolderRepositoryManager, ReposManagerState } from './folderRepositoryManager';
2829
import { GitHubRepository } from './githubRepository';
@@ -40,6 +41,8 @@ const PUSH_CHANGES = vscode.l10n.t('Include changes');
4041
const CONTINUE_WITHOUT_PUSHING = vscode.l10n.t('Ignore changes');
4142
const CONTINUE_AND_DO_NOT_ASK_AGAIN = vscode.l10n.t('Continue and don\'t ask again');
4243

44+
const CONTINUE_TRUNCATION = vscode.l10n.t('Continue with truncation');
45+
4346
const COPILOT = '@copilot';
4447

4548
const body_suffix = vscode.l10n.t('Created from VS Code via the [GitHub Pull Request](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension.');
@@ -709,33 +712,32 @@ export class CopilotRemoteAgentManager extends Disposable {
709712
return { error: vscode.l10n.t('Failed to configure base branch \'{0}\' does not exist on the remote repository \'{1}/{2}\'. Please create the remote branch first.', base_ref, owner, repo), state: 'error' };
710713
}
711714

712-
let title = prompt;
713-
const titleMatch = problemContext?.match(/TITLE: \s*(.*)/i);
714-
if (titleMatch && titleMatch[1]) {
715-
title = titleMatch[1].trim();
716-
}
715+
const title = extractTitle(problemContext);
716+
const { problemStatement, isTruncated } = truncatePrompt(prompt, problemContext);
717717

718-
const formatBodyPlaceholder = (problemContext: string): string => {
719-
const header = vscode.l10n.t('Coding agent has begun work on **{0}** and will replace this description as work progresses.', title);
720-
const collapsedContext = `<details><summary>${vscode.l10n.t('See problem context')}</summary>\n\n${problemContext}\n\n</details>`;
721-
return `${header}\n\n${collapsedContext}`;
722-
};
723-
724-
let isTruncated = false;
725-
if (problemContext && (problemContext.length + prompt.length >= MAX_PROBLEM_STATEMENT_LENGTH)) {
726-
isTruncated = true;
727-
Logger.warn(`Truncating problemContext as it will cause us to exceed maximum problem_statement length (${MAX_PROBLEM_STATEMENT_LENGTH})`, CopilotRemoteAgentManager.ID);
728-
const availableLength = MAX_PROBLEM_STATEMENT_LENGTH - prompt.length;
729-
problemContext = problemContext.slice(-availableLength);
718+
if (isTruncated) {
719+
const truncationResult = await vscode.window.showWarningMessage(
720+
vscode.l10n.t('Prompt size exceeded'), { modal: true, detail: vscode.l10n.t('Your prompt will be truncated to fit within coding agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION);
721+
const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION;
722+
/* __GDPR__
723+
"remoteAgent.truncation" : {
724+
"isCancelled" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
725+
}
726+
*/
727+
this.telemetry.sendTelemetryEvent('remoteAgent.truncation', {
728+
isCancelled: String(userCancelled),
729+
});
730+
if (userCancelled) {
731+
return { error: vscode.l10n.t('User cancelled due to truncation'), state: 'error' };
732+
}
730733
}
731734

732-
const problemStatement: string = `${prompt}\n${problemContext ?? ''}`;
733735
const payload: RemoteAgentJobPayload = {
734736
problem_statement: problemStatement,
735737
event_type: 'visual_studio_code_remote_agent_tool_invoked',
736738
pull_request: {
737739
title,
738-
body_placeholder: formatBodyPlaceholder(problemContext || prompt),
740+
body_placeholder: formatBodyPlaceholder(title),
739741
base_ref,
740742
body_suffix,
741743
...(head_ref && { head_ref })
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import Logger from '../common/logger';
8+
import { MAX_PROBLEM_STATEMENT_LENGTH } from './copilotApi';
9+
10+
/**
11+
* Truncation utility to ensure the problem statement sent to Copilot API is under the maximum length.
12+
* Truncation is not ideal. The caller providing the prompt/context should be summarizing so this is a no-op whenever possible.
13+
*
14+
* @param prompt The final message submitted by the user
15+
* @param context Any additional context collected by the caller (chat history, open files, etc...)
16+
* @returns A complete 'problem statement' string that is under the maximum length, and a flag indicating if truncation occurred
17+
*/
18+
export function truncatePrompt(prompt: string, context?: string): { problemStatement: string; isTruncated: boolean } {
19+
// Prioritize the userPrompt
20+
// Take the last n characters that fit within the limit
21+
if (prompt.length >= MAX_PROBLEM_STATEMENT_LENGTH) {
22+
Logger.warn(`Truncation: Prompt length ${prompt.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`);
23+
prompt = prompt.slice(-MAX_PROBLEM_STATEMENT_LENGTH);
24+
return { problemStatement: prompt, isTruncated: true };
25+
}
26+
27+
if (context && (prompt.length + context.length >= MAX_PROBLEM_STATEMENT_LENGTH)) {
28+
const availableLength = MAX_PROBLEM_STATEMENT_LENGTH - prompt.length - 2 /* new lines */;
29+
Logger.warn(`Truncation: Combined prompt and context length ${prompt.length + context.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`);
30+
context = context.slice(-availableLength);
31+
return {
32+
problemStatement: prompt + (context ? `\n\n${context}` : ''),
33+
isTruncated: true
34+
};
35+
}
36+
37+
// No truncation occurred
38+
return {
39+
problemStatement: prompt + (context ? `\n\n${context}` : ''),
40+
isTruncated: false
41+
};
42+
}
43+
44+
export function extractTitle(context: string | undefined): string | undefined {
45+
if (!context) {
46+
return;
47+
}
48+
const titleMatch = context.match(/TITLE: \s*(.*)/i);
49+
if (titleMatch && titleMatch[1]) {
50+
return titleMatch[1].trim();
51+
}
52+
}
53+
54+
export function formatBodyPlaceholder(title: string | undefined): string {
55+
return vscode.l10n.t('Coding agent has begun work on **{0}** and will update this pull request as work progresses.', title || vscode.l10n.t('your request'));
56+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { default as assert } from 'assert';
7+
import { truncatePrompt, extractTitle, formatBodyPlaceholder } from '../../github/copilotRemoteAgentUtils';
8+
import { MAX_PROBLEM_STATEMENT_LENGTH } from '../../github/copilotApi';
9+
10+
describe('copilotRemoteAgentUtils', () => {
11+
12+
describe('truncatePrompt', () => {
13+
it('should return prompt and context unchanged when under limit', () => {
14+
const prompt = 'This is a short prompt';
15+
const context = 'This is some additional context';
16+
const result = truncatePrompt(prompt, context);
17+
assert.strictEqual(result.problemStatement, `${prompt}\n\n${context}`);
18+
assert.strictEqual(result.isTruncated, false);
19+
});
20+
21+
it('should return only prompt when no context provided and under limit', () => {
22+
const prompt = 'This is a short prompt';
23+
const result = truncatePrompt(prompt);
24+
assert.strictEqual(result.problemStatement, 'This is a short prompt');
25+
assert.strictEqual(result.isTruncated, false);
26+
});
27+
28+
it('should truncate prompt when it exceeds the maximum length', () => {
29+
const longPrompt = 'a'.repeat(MAX_PROBLEM_STATEMENT_LENGTH + 100);
30+
const result = truncatePrompt(longPrompt);
31+
assert.strictEqual(result.problemStatement.length, MAX_PROBLEM_STATEMENT_LENGTH);
32+
assert.strictEqual(result.isTruncated, true);
33+
});
34+
35+
it('should truncate context when combined length exceeds limit', () => {
36+
const prompt = 'Short prompt';
37+
const longContext = 'b'.repeat(MAX_PROBLEM_STATEMENT_LENGTH);
38+
39+
const result = truncatePrompt(prompt, longContext);
40+
41+
assert.strictEqual(result.isTruncated, true);
42+
assert(result.problemStatement.startsWith(prompt));
43+
assert(result.problemStatement.includes('\n\n'));
44+
const expectedAvailableLength = MAX_PROBLEM_STATEMENT_LENGTH - prompt.length;
45+
const expectedContext = longContext.slice(-expectedAvailableLength + 2);
46+
assert.strictEqual(result.problemStatement, `${prompt}\n\n${expectedContext}`);
47+
});
48+
49+
it('long prompts are prioritized when truncating', () => {
50+
const longPrompt = 'a'.repeat(MAX_PROBLEM_STATEMENT_LENGTH + 100);
51+
const context = 'B';
52+
53+
const result = truncatePrompt(longPrompt, context);
54+
55+
assert.strictEqual(result.isTruncated, true);
56+
assert.strictEqual(result.problemStatement.length, MAX_PROBLEM_STATEMENT_LENGTH);
57+
assert(!result.problemStatement.includes(context));
58+
});
59+
});
60+
61+
describe('extractTitle', () => {
62+
it('should extract title from context with TITLE prefix', () => {
63+
const context = 'Some initial text\nTITLE: Fix authentication bug\nSome other content';
64+
65+
const result = extractTitle(context);
66+
67+
assert.strictEqual(result, 'Fix authentication bug');
68+
});
69+
70+
it('should extract title with case insensitive matching', () => {
71+
const context = 'Some text\ntitle: Add new feature\nMore text';
72+
73+
const result = extractTitle(context);
74+
75+
assert.strictEqual(result, 'Add new feature');
76+
});
77+
78+
it('should extract title with extra whitespace', () => {
79+
const context = 'TITLE: Refactor code structure \n';
80+
81+
const result = extractTitle(context);
82+
83+
assert.strictEqual(result, 'Refactor code structure');
84+
});
85+
86+
it('should return undefined when no title is found', () => {
87+
const context = 'Some text without any title marker\nJust regular content';
88+
89+
const result = extractTitle(context);
90+
91+
assert.strictEqual(result, undefined);
92+
});
93+
94+
it('should return undefined when context is undefined', () => {
95+
const result = extractTitle(undefined);
96+
97+
assert.strictEqual(result, undefined);
98+
});
99+
100+
it('should return undefined when context is empty string', () => {
101+
const result = extractTitle('');
102+
103+
assert.strictEqual(result, undefined);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)