agentHost: attribute edits to the committed Auto tier by bhavyaus · Pull Request #340437 · microsoft/vscode · GitHub
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
2 changes: 2 additions & 0 deletions src/vs/editor/common/textModelEditSource.ts
4 changes: 4 additions & 0 deletions src/vs/platform/agentHost/common/fileEditAttribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { createDecorator } from '../../instantiation/common/instantiation.js';
import { stringHash } from '../../../base/common/hash.js';
import type { AutoModeRoutingTier } from './autoModeTiers.js';
import { IOffsetEdit } from './diffComputeService.js';
import { ToolResultFileEditContent } from './state/sessionState.js';
import { URI } from '../../../base/common/uri.js';
Expand All @@ -23,6 +24,7 @@ interface IFileEditAttributionMarkerBase {

export interface IFileEditAttributionSource {
readonly modelId?: string;
readonly autoTier?: string;
readonly conversationId: string;
readonly chatSessionId?: string;
readonly requestId: string;
Expand Down Expand Up @@ -61,6 +63,7 @@ export interface IAgentEditAttribution {
readonly afterText: string;
readonly changes: readonly IOffsetEdit[];
readonly modelId: string | undefined;
readonly autoTier?: AutoModeRoutingTier;
readonly toolName: string;
}

Expand Down Expand Up @@ -172,6 +175,7 @@ function isFileEditAttributionSource(source: unknown): source is IFileEditAttrib
}
const candidate = source as Partial<IFileEditAttributionSource>;
return (candidate.modelId === undefined || typeof candidate.modelId === 'string') &&
(candidate.autoTier === undefined || typeof candidate.autoTier === 'string') &&
typeof candidate.conversationId === 'string' &&
(candidate.chatSessionId === undefined || typeof candidate.chatSessionId === 'string') &&
typeof candidate.requestId === 'string' &&
Expand Down
75 changes: 64 additions & 11 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { getCopilotHomePath, getCopilotMcpConfigurationPath } from '../../../env
import { AgentHostHydraFusionEnabledSettingId, CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js';
import { logSettingExperimentTrigger } from '../../../telemetry/common/experimentTrigger.js';
import { withCustomizationEnablement } from '../../common/customizationEnablement.js';
import type { AutoModeTier } from '../../common/autoModeTiers.js';
import { isAutoModeRoutingTier, type AutoModeRoutingTier, type AutoModeTier } from '../../common/autoModeTiers.js';
import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js';
import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js';
import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js';
Expand Down Expand Up @@ -154,6 +154,16 @@ function readSubagentTaskModelSource(data: object): AgentSubagentTaskModelSource
}
}

/** The runtime-reported Auto tier, only while the model is Auto; never inferred or defaulted. */
function getCommittedAutoTier(model: string | undefined, autoTier: unknown): AutoModeRoutingTier | undefined {
return isAutoModel(model) && isAutoModeRoutingTier(autoTier) ? autoTier : undefined;
}

/** A known tier, or the pending runtime read for the configuration in effect when it was captured. */
type AutoTierSnapshot = AutoModeRoutingTier | undefined | Promise<AutoModeRoutingTier | undefined>;

const AUTO_TIER_READ_TIMEOUT = 5_000;

function getClientToolSdkPolicy(toolName: string): IClientToolSdkPolicy {
return CLIENT_TOOL_SDK_POLICIES.get(toolName) ?? DEFAULT_CLIENT_TOOL_SDK_POLICY;
}
Expand Down Expand Up @@ -203,6 +213,7 @@ interface ICopilotActiveToolCall {
readonly mcpServerName: string | undefined;
readonly contributor: ToolCallContributor | undefined;
readonly intention: string | undefined;
readonly autoTier: AutoTierSnapshot;
meta: IToolCallMeta | undefined;
}

Expand Down Expand Up @@ -1014,6 +1025,8 @@ export class CopilotAgentSession extends Disposable {
* subagent outlives the root turn that spawned it when steering mints a new one.
*/
private readonly _autoModeResolvedByToolCallId = new Map<string, NonNullable<UsageInfoMeta['autoModeResolved']>>();
private _committedAutoTier: AutoTierSnapshot;
private readonly _committedAutoTierBySubagentToolCallId = new Map<string, AutoModeRoutingTier>();
private readonly _activeSubagentAgentIds = new Set<string>();
private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap<string, RunOnceScheduler>());
private readonly _pendingSubagentLifecycleCompletions = new Map<string, { readonly toolCallId: string; readonly activityRevision: number | undefined; readonly outcome?: SubagentTurnOutcome }>();
Expand Down Expand Up @@ -3200,6 +3213,7 @@ export class CopilotAgentSession extends Disposable {
}));
this._subscribeToEvents();
this._subscribeToSdkEvents();
this._readCommittedAutoTier(wrapper);
this._subscribeForMemoInvalidation();
this._subscribeForInstructionsCollectedTelemetry();
this._subscribeToPermissionConfigChanges();
Expand Down Expand Up @@ -6888,6 +6902,9 @@ export class CopilotAgentSession extends Disposable {
mcpServerName,
contributor,
intention,
autoTier: parentToolCallId && !this._fusionPhaseLabels.has(parentToolCallId)
? this._committedAutoTierBySubagentToolCallId.get(parentToolCallId)
: this._committedAutoTier,
meta: undefined,
});
const existingApproval = this._toolApprovalRecords.get(e.data.toolCallId);
Expand Down Expand Up @@ -7188,17 +7205,18 @@ export class CopilotAgentSession extends Disposable {
outputDatabase.dispose();
}
}
for (const filePath of filePaths) {
if (!isCurrent()) {
return;
}
try {
const fileEdit = await this._editTracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, modelId, turn?.clientContext, chatUri);
if (fileEdit) {
content.push(fileEdit);
}
} catch (err) {
if (!isCurrent()) {
return;
}
// Claim every file before awaiting so a later tool cannot take this tool's snapshots.
const fileEdits = filePaths.map(filePath => this._editTracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, modelId, turn?.clientContext, chatUri, tracked.autoTier)
.catch(err => {
this._logService.warn(`[Copilot:${sessionId}] Failed to take completed edit`, err);
return undefined;
}));
for (const fileEdit of await Promise.all(fileEdits)) {
if (fileEdit) {
content.push(fileEdit);
}
}
if (isCurrent()) {
Expand Down Expand Up @@ -8842,6 +8860,26 @@ export class CopilotAgentSession extends Disposable {
});
}

/** `session.start`/`session.resume` fire before this session subscribes. A result superseded by a later model change resolves as unknown. */
private _readCommittedAutoTier(wrapper: CopilotSessionWrapper): void {
const result = new DeferredPromise<AutoModeRoutingTier | undefined>();
const read = result.p;
this._committedAutoTier = read;
void (async () => {
let tier: AutoModeRoutingTier | undefined;
try {
const current = await raceTimeout(wrapper.session.rpc.model.getCurrent(), AUTO_TIER_READ_TIMEOUT);
tier = current && this._committedAutoTier === read && this._wrapper === wrapper ? getCommittedAutoTier(current.modelId, current.autoTier) : undefined;
} catch (error) {
this._logService.trace(`[Copilot:${this.sessionId}] Failed to read the committed Auto tier: ${getErrorMessage(error)}`);
}
if (this._committedAutoTier === read) {
this._committedAutoTier = tier;
}
await result.complete(tier);
})();
}

private _subscribeToSdkEvents(): void {
const wrapper = this._wrapper;
const sessionId = this.sessionId;
Expand Down Expand Up @@ -8991,15 +9029,30 @@ export class CopilotAgentSession extends Disposable {

this._register(wrapper.onSessionModelChange(e => {
this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`);
const committedAutoTier = getCommittedAutoTier(e.data.newModel, e.data.autoTier);
// The runtime omits `autoTier` on Auto-to-Auto changes that keep its committed preference.
const keepsAutoTier = e.data.autoTier === undefined && isAutoModel(e.data.previousModel) && isAutoModel(e.data.newModel);
if (e.agentId) {
const parentToolCallId = this._parentToolCallIdForSubagentEvent(e);
if (parentToolCallId) {
if (e.data.previousModel !== e.data.newModel) {
this._autoModeResolvedByToolCallId.delete(parentToolCallId);
}
if (committedAutoTier) {
this._committedAutoTierBySubagentToolCallId.set(parentToolCallId, committedAutoTier);
} else if (!keepsAutoTier) {
this._committedAutoTierBySubagentToolCallId.delete(parentToolCallId);
}
this._updateSubagentModel(parentToolCallId, e.data.newModel);
}
} else {
if (!keepsAutoTier) {
if (e.data.autoTier === undefined && isAutoModel(e.data.newModel)) {
this._readCommittedAutoTier(wrapper);
} else {
this._committedAutoTier = committedAutoTier;
}
}
this._promptCacheRefreshGeneration++;
if (e.data.previousModel !== e.data.newModel) {
this._setPromptCacheState(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import { execFile } from 'child_process';
import { promisify } from 'util';
import { sumBy } from '../../../../base/common/arrays.js';
import { IntervalTimer, raceTimeout, SequencerByKey } from '../../../../base/common/async.js';
import { groupByMap } from '../../../../base/common/collections.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { dirname } from '../../../../base/common/path.js';
import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js';
Expand Down Expand Up @@ -47,9 +49,11 @@ interface IAttributedInterval {

interface ISourceStatistics {
readonly trackingKey: string;
readonly groupKey: string;
readonly sourceKey: string;
readonly sourceKeyCleaned: string;
readonly modelId: string | undefined;
readonly autoTier: string | undefined;
readonly conversationId: string;
readonly chatSessionId: string | undefined;
readonly requestId: string;
Expand Down Expand Up @@ -305,18 +309,24 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi

const provider = edit.provider ?? getSessionProvider(edit.sessionUri);
const modelSegment = edit.modelId ? `-$modelId:${edit.modelId}` : '';
const sourceKey = `source:Chat.applyEdits${modelSegment}-$harness:${provider}-$origin:agentHost`;
// Matches the workbench edit-source key order ($modelId, $autoTier, ..., $harness, $origin).
const autoTierSegment = edit.autoTier ? `-$autoTier:${edit.autoTier}` : '';
const sourceKey = `source:Chat.applyEdits${modelSegment}${autoTierSegment}-$harness:${provider}-$origin:agentHost`;
Comment thread
bhavyaus marked this conversation as resolved.
const groupSourceKey = `source:Chat.applyEdits${modelSegment}-$harness:${provider}-$origin:agentHost`;
const conversationId = AgentSession.id(edit.sessionUri);
const chatUri = edit.chatUri ?? (isAhpChatChannel(edit.sessionUri) ? edit.sessionUri : undefined);
const chatSessionId = chatUri === undefined ? undefined : getTelemetryChatSessionId(chatUri);
const trackingKey = chatSessionId === undefined ? sourceKey : JSON.stringify([sourceKey, conversationId, chatSessionId]);
const toTrackingKey = (key: string) => chatSessionId === undefined ? key : JSON.stringify([key, conversationId, chatSessionId]);
const trackingKey = toTrackingKey(sourceKey);
let source = resource.sources.get(trackingKey);
if (!source) {
source = {
trackingKey,
groupKey: toTrackingKey(groupSourceKey),
sourceKey,
sourceKeyCleaned: `source:Chat.applyEdits-$harness:${provider}-$origin:agentHost`,
modelId: edit.modelId,
autoTier: edit.autoTier,
conversationId,
chatSessionId,
requestId: edit.turnId,
Expand All @@ -335,6 +345,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi
afterDigest: createFileEditContentDigest(edit.afterText),
source: {
modelId: edit.modelId,
...(edit.autoTier !== undefined ? { autoTier: edit.autoTier } : {}),
conversationId,
...(chatSessionId !== undefined ? { chatSessionId } : {}),
requestId: edit.turnId,
Expand Down Expand Up @@ -758,9 +769,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi
trigger,
statsUuid,
languageId: undefined,
sources: Array.from(resource.sources.values())
.toSorted((a, b) => (retainedBySource.get(b.trackingKey) ?? 0) - (retainedBySource.get(a.trackingKey) ?? 0))
.slice(0, 30),
sources: selectReportedSources(resource.sources.values(), retainedBySource),
retainedBySource,
agentModifiedCount: Array.from(retainedBySource.values()).reduce((sum, value) => sum + value, 0),
externalModifiedCount,
Expand Down Expand Up @@ -797,6 +806,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi
extensionId: undefined,
extensionVersion: undefined,
modelId: source.modelId,
...(source.autoTier !== undefined ? { autoTier: source.autoTier } : {}),
trigger: prepared.trigger,
languageId: prepared.languageId,
statsUuid: prepared.statsUuid,
Expand All @@ -819,6 +829,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi
extensionId: '',
extensionVersion: '',
modelId: data.modelId ?? '',
...(source.autoTier !== undefined ? { autoTier: source.autoTier } : {}),
trigger: data.trigger,
languageId: data.languageId ?? '',
statsUuid: data.statsUuid,
Expand Down Expand Up @@ -1159,6 +1170,17 @@ function resourceKey(sessionUri: string, fileKey: string): string {
return `${sessionUri}\0${fileKey}`;
}

const MAX_REPORTED_SOURCE_GROUPS = 30;

/** Caps reported sources before subdividing them by Auto tier, so tiers never displace other sources. */
function selectReportedSources(sources: Iterable<ISourceStatistics>, retainedBySource: ReadonlyMap<string, number>): ISourceStatistics[] {
const retained = (source: ISourceStatistics) => retainedBySource.get(source.trackingKey) ?? 0;
return Array.from(groupByMap(Array.from(sources), source => source.groupKey).values(), group => ({ group, retained: sumBy(group, retained) }))
.toSorted((a, b) => b.retained - a.retained)
.slice(0, MAX_REPORTED_SOURCE_GROUPS)
.flatMap(({ group }) => group.toSorted((a, b) => retained(b) - retained(a)));
}

function combinePreparedFlushes(
flushes: readonly IPreparedFlush[],
fileKey: string,
Expand Down Expand Up @@ -1195,9 +1217,7 @@ function combinePreparedFlushes(
trigger,
statsUuid,
languageId,
sources: Array.from(sources.values())
.toSorted((a, b) => (retainedBySource.get(b.trackingKey) ?? 0) - (retainedBySource.get(a.trackingKey) ?? 0))
.slice(0, 30),
sources: selectReportedSources(sources.values(), retainedBySource),
retainedBySource,
agentModifiedCount: Array.from(retainedBySource.values()).reduce((sum, value) => sum + value, 0),
externalModifiedCount: flushes.reduce((sum, flush) => sum + flush.externalModifiedCount, 0),
Expand Down
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts
Loading
Loading