Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/vs/platform/agentHost/common/agentHostSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,8 +759,8 @@ export const platformRootSchema = createSchema({
type: 'string',
title: localize('agentHost.config.showExternalSessions.title', "Show External Agent Sessions"),
description: localize('agentHost.config.showExternalSessions.description', "Controls whether sessions created outside the Agent Host are included in the session catalog."),
enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.All, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days],
default: ChatExternalSessionsMode.Last7Days,
enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.Recent, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days, ChatExternalSessionsMode.All],
default: ChatExternalSessionsMode.None,
}),
[AgentHostCopilotMultiRootEnabledConfigKey]: schemaProperty<boolean>({
type: 'boolean',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,39 +102,39 @@ configurationRegistry.registerConfiguration({
properties: {
[AgentMergeSettingId.Enabled]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.enabled', "Enables the experimental Agent Merge controller and its commands. Agent Merge can monitor an agent session's pull request, ask the agent to address selected blockers, and optionally merge the pull request when it is ready."),
description: nls.localize('chat.agentMerge.enabled', "Enables the experimental Agent Merge controller and its commands. Agent Merge can monitor an agent session's pull request, ask the agent to address selected blockers, and optionally merge the pull request when it is ready."),
default: product.quality !== 'stable',
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
agentHost: { key: AgentMergeConfigKey.Enabled },
},
[AgentMergeSettingId.AddressReviews]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.addressReviews', "Controls whether enabled Agent Merge sessions address unresolved review threads, changes-requested reviews, and new pull request comments from repository maintainers or the Copilot pull request reviewer."),
description: nls.localize('chat.agentMerge.addressReviews', "Controls whether enabled Agent Merge sessions address unresolved review threads, changes-requested reviews, and new pull request comments from repository maintainers or the Copilot pull request reviewer."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
agentHost: { key: AgentMergeConfigKey.AddressReviews },
},
[AgentMergeSettingId.FixCI]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.fixCI', "Controls whether enabled Agent Merge sessions ask the agent to fix failed required CI checks."),
description: nls.localize('chat.agentMerge.fixCI', "Controls whether enabled Agent Merge sessions ask the agent to fix failed required CI checks."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
agentHost: { key: AgentMergeConfigKey.FixCI },
},
[AgentMergeSettingId.ResolveConflicts]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.resolveConflicts', "Controls whether enabled Agent Merge sessions ask the agent to update branches that are behind or resolve merge conflicts."),
description: nls.localize('chat.agentMerge.resolveConflicts', "Controls whether enabled Agent Merge sessions ask the agent to update branches that are behind or resolve merge conflicts."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
agentHost: { key: AgentMergeConfigKey.ResolveConflicts },
},
[AgentMergeSettingId.MergePullRequest]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.mergePullRequest', "Controls whether the Agent Host automatically merges or enqueues pull requests for enabled Agent Merge sessions after all selected maintenance work is complete."),
description: nls.localize('chat.agentMerge.mergePullRequest', "Controls whether the Agent Host automatically merges or enqueues pull requests for enabled Agent Merge sessions after all selected maintenance work is complete."),
default: false,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
Expand All @@ -144,20 +144,20 @@ configurationRegistry.registerConfiguration({
type: 'string',
enum: ['auto', 'squash', 'merge', 'rebase'],
enumDescriptions: [
nls.localize('chat.agentHost.agentMerge.mergeMethod.auto', "Uses the first repository-compatible method in this order: squash, merge commit, rebase."),
nls.localize('chat.agentHost.agentMerge.mergeMethod.squash', "Uses squash merge when the repository permits it."),
nls.localize('chat.agentHost.agentMerge.mergeMethod.merge', "Uses a merge commit when the repository permits it."),
nls.localize('chat.agentHost.agentMerge.mergeMethod.rebase', "Uses rebase merge when the repository permits it."),
nls.localize('chat.agentMerge.mergeMethod.auto', "Uses the first repository-compatible method in this order: squash, merge commit, rebase."),
nls.localize('chat.agentMerge.mergeMethod.squash', "Uses squash merge when the repository permits it."),
nls.localize('chat.agentMerge.mergeMethod.merge', "Uses a merge commit when the repository permits it."),
nls.localize('chat.agentMerge.mergeMethod.rebase', "Uses rebase merge when the repository permits it."),
],
description: nls.localize('chat.agentHost.agentMerge.mergeMethod', "Controls the native merge method used by Agent Merge."),
description: nls.localize('chat.agentMerge.mergeMethod', "Controls the native merge method used by Agent Merge."),
default: 'auto',
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
agentHost: { key: AgentMergeConfigKey.MergeMethod },
},
[AgentMergeSettingId.ReplyAttribution]: {
type: 'boolean',
description: nls.localize('chat.agentHost.agentMerge.replyAttribution', "Controls whether review-thread replies posted by Agent Merge include an automated-reply attribution note."),
description: nls.localize('chat.agentMerge.replyAttribution', "Controls whether review-thread replies posted by Agent Merge include an automated-reply attribution note."),
default: true,
scope: ConfigurationScope.APPLICATION,
tags: ['experimental'],
Expand Down
97 changes: 72 additions & 25 deletions src/vs/platform/agentHost/common/agentMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,23 @@ export const AgentMergeConfigKey = {
} as const;

export const AgentMergeSettingId = {
Enabled: 'chat.agentHost.agentMerge.enabled',
AddressReviews: 'chat.agentHost.agentMerge.addressReviews',
FixCI: 'chat.agentHost.agentMerge.fixCI',
ResolveConflicts: 'chat.agentHost.agentMerge.resolveConflicts',
MergePullRequest: 'chat.agentHost.agentMerge.mergePullRequest',
MergeMethod: 'chat.agentHost.agentMerge.mergeMethod',
ReplyAttribution: 'chat.agentHost.agentMerge.replyAttribution',
Enabled: 'chat.agentMerge.enabled',
AddressReviews: 'chat.agentMerge.addressReviews',
FixCI: 'chat.agentMerge.fixCI',
ResolveConflicts: 'chat.agentMerge.resolveConflicts',
MergePullRequest: 'chat.agentMerge.mergePullRequest',
MergeMethod: 'chat.agentMerge.mergeMethod',
ReplyAttribution: 'chat.agentMerge.replyAttribution',
} as const;

export type AgentMergeAction = 'addressReviews' | 'fixCI' | 'resolveConflicts' | 'mergePullRequest';
/**
* Work the agent itself can be asked to perform. Merging is deliberately absent:
* it is executed by the host, never delegated to a model.
*/
export type AgentMergeRepairAction = 'addressReviews' | 'fixCI' | 'resolveConflicts';

/** A user-authorizable Agent Merge action, including the host-executed merge. */
export type AgentMergeAction = AgentMergeRepairAction | 'mergePullRequest';
export type AgentMergeMethod = 'auto' | 'squash' | 'merge' | 'rebase';

export interface AgentMergeActions {
Expand Down Expand Up @@ -61,6 +68,11 @@ export interface AgentMergeReviewThreadContext {
readonly id: string;
readonly path?: string;
readonly line?: number;
/** Authorized comments in the thread, oldest first, so later follow-ups are visible. */
readonly comments: readonly AgentMergeFeedbackComment[];
}

export interface AgentMergeFeedbackComment {
readonly author?: string;
readonly body: string;
}
Expand Down Expand Up @@ -146,7 +158,7 @@ export type AgentMergeGateResult =
| { readonly kind: 'indeterminate'; readonly reason: string }
| { readonly kind: 'terminal' }
| { readonly kind: 'noWork'; readonly waitingOnChecks: boolean; readonly fingerprint: string }
| { readonly kind: 'prompt'; readonly actions: readonly AgentMergeAction[]; readonly fingerprint: string; readonly context: AgentMergePromptContext }
| { readonly kind: 'prompt'; readonly actions: readonly AgentMergeRepairAction[]; readonly fingerprint: string; readonly context: AgentMergePromptContext }
| { readonly kind: 'merge'; readonly fingerprint: string };

export interface AgentMergePromptContext {
Expand All @@ -156,14 +168,23 @@ export interface AgentMergePromptContext {
readonly baseRef: string;
readonly headRef: string;
readonly reviewThreads: readonly AgentMergeReviewThreadContext[];
readonly reviewSummaries: readonly string[];
readonly newComments: readonly string[];
readonly reviewSummaries: readonly AgentMergeFeedbackComment[];
readonly newComments: readonly AgentMergeFeedbackComment[];
readonly failedChecks: readonly string[];
readonly behind: boolean;
readonly conflicting: boolean;
readonly commentWatermark: string;
}

/** Caps that keep an autonomous prompt bounded regardless of pull request size. */
const maximumReviewThreads = 10;
const maximumCommentsPerThread = 5;
const maximumReviewSummaries = 5;
const maximumNewComments = 5;
const maximumFailedChecks = 20;
const maximumFeedbackBodyLength = 1_000;
const maximumFeedbackBudget = 20_000;

const maintainerAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const copilotPullRequestReviewerId = '175728472';
const copilotPullRequestReviewerLogins = new Set(['copilot', 'copilot-pull-request-reviewer[bot]']);
Expand Down Expand Up @@ -247,6 +268,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Truncates feedback bodies against one shared character budget so a
* comment-heavy pull request cannot grow the autonomous prompt without bound.
*/
class FeedbackBudget {

private _remaining: number;

constructor(budget: number) {
this._remaining = budget;
}

take(author: string | undefined, body: string | undefined): AgentMergeFeedbackComment {
const text = (body ?? '').slice(0, Math.max(0, Math.min(maximumFeedbackBodyLength, this._remaining)));
this._remaining -= text.length;
return {
...(author ? { author } : {}),
body: text,
};
}
}

export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration: AgentMergeConfiguration, commentWatermark: string): AgentMergeGateResult {
const core = snapshot.core;
if (core.status !== 'ready' || !core.complete || !core.value) {
Expand Down Expand Up @@ -281,7 +324,7 @@ export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration:
const mergeability = snapshot.mergeability.value!;
const behind = mergeability.mergeStateStatus?.toUpperCase() === 'BEHIND';
const conflicting = mergeability.mergeable === 'CONFLICTING';
const actions: AgentMergeAction[] = [];
const actions: AgentMergeRepairAction[] = [];
if (configuration.addressReviews && (reviewThreads.length > 0 || changesRequested.length > 0 || newComments.length > 0)) {
actions.push('addressReviews');
}
Expand All @@ -292,25 +335,29 @@ export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration:
actions.push('resolveConflicts');
}

const budget = new FeedbackBudget(maximumFeedbackBudget);
const context: AgentMergePromptContext = {
pullRequestUrl: core.value.url,
title: core.value.title,
headSha: core.value.headSha,
baseRef: core.value.baseRef,
headRef: core.value.headRef,
reviewThreads: reviewThreads.slice(0, 20).map(thread => {
const comment = thread.comments.find(candidate => isAgentMergeFeedbackAuthor(candidate.author));
return {
id: thread.id,
...(thread.path ? { path: thread.path } : {}),
...(thread.line !== undefined ? { line: thread.line } : {}),
...(comment?.author?.login ? { author: comment.author.login } : {}),
body: (comment?.body ?? '').slice(0, 1_000),
};
}),
reviewSummaries: changesRequested.map(review => review.body ?? `Changes requested by ${review.author?.login ?? 'reviewer'}`),
newComments: newComments.map(comment => comment.body ?? `Comment by ${comment.author?.login ?? 'reviewer'}`),
failedChecks: checks.failed.map(check => check.name),
reviewThreads: reviewThreads.slice(0, maximumReviewThreads).map(thread => ({
id: thread.id,
...(thread.path ? { path: thread.path } : {}),
...(thread.line !== undefined ? { line: thread.line } : {}),
comments: thread.comments
.filter(comment => isAgentMergeFeedbackAuthor(comment.author))
.slice(-maximumCommentsPerThread)
.map(comment => budget.take(comment.author?.login, comment.body)),
})),
reviewSummaries: changesRequested
.slice(-maximumReviewSummaries)
.map(review => budget.take(review.author?.login, review.body)),
newComments: newComments
.slice(-maximumNewComments)
.map(comment => budget.take(comment.author?.login, comment.body)),
failedChecks: checks.failed.slice(0, maximumFailedChecks).map(check => check.name),
behind,
conflicting,
commentWatermark: newComments.reduce((latest, comment) => comment.createdAt && comment.createdAt > latest ? comment.createdAt : latest, commentWatermark),
Expand Down
5 changes: 4 additions & 1 deletion src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { equals } from '../../../base/common/objects.js';
import { ILogService } from '../../log/common/log.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { TelemetryLevel } from '../../telemetry/common/telemetry.js';
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js';
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js';
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js';
import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js';
Expand Down Expand Up @@ -280,6 +280,8 @@ export class AgentHostStateManager extends Disposable {

private readonly _onDidChangeSessionWorkingDirectories = this._register(new Emitter<{ session: string }>());
readonly onDidChangeSessionWorkingDirectories: Event<{ session: string }> = this._onDidChangeSessionWorkingDirectories.event;
private readonly _onDidChangeSessionSummary = this._register(new Emitter<{ session: string; changes: SessionSummaryChangedParams['changes'] }>());
readonly onDidChangeSessionSummary: Event<{ session: string; changes: SessionSummaryChangedParams['changes'] }> = this._onDidChangeSessionSummary.event;

constructor(
@ILogService private readonly _logService: ILogService,
Expand Down Expand Up @@ -309,6 +311,7 @@ export class AgentHostStateManager extends Disposable {
return entry ? this._toSummary(session, entry) : undefined;
},
(session, changes) => {
this._onDidChangeSessionSummary.fire({ session, changes });
if (this._publishedSessionSummaries.has(session)) {
this._onDidEmitNotification.fire({
type: 'root/sessionSummaryChanged',
Expand Down
Loading
Loading