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
162 changes: 162 additions & 0 deletions src/vs/platform/agentHost/common/agentSdkSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { RootState } from './state/protocol/state.js';

/**
* Private side-channel describing whether each agent's SDK is on disk yet, and
* what the user can do about it. Rides `publishRootTransientValues` rather than
* AHP proper, alongside `vscode.codexAccount`: the protocol files here are
* generated and version-pinned, so promoting this is a cross-repo change.
*
* One key per agent rather than one key holding a map — transient values are a
* shallow patch, so a shared key would let agents erase each other's entry.
*/
const AGENT_SDK_SETUP_STATUS_KEY_PREFIX = 'vscode.agentSdkSetup.status.';

export const AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY = 'vscode.agentSdkSetup.downloadRequest';

export function agentSdkSetupStatusKey(agent: string): string {
return `${AGENT_SDK_SETUP_STATUS_KEY_PREFIX}${agent}`;
}

/**
* Whether the agent's SDK can be loaded without a network fetch.
*
* Deliberately the *only* thing on the wire: account state is derivable from the
* model list, which already flows over AHP — `ready` plus zero models means "no
* account" — and publishing it too would be two sources for one truth.
*/
export type AgentSdkDownloadStatus = 'notDownloaded' | 'downloading' | 'ready';

/**
* What an agent declares about its own setup. Capabilities, never UI: no
* user-facing strings ride this channel, because localization belongs in the
* workbench.
*/
export interface IAgentSdkSetupInfo {
/** Agent/provider id, e.g. `'claude'`. */
readonly agent: string;
readonly download: AgentSdkDownloadStatus;
/**
* Where the user goes to finish setup, for the agents whose setup happens
* outside the app (`claude login`, an exported API key).
*/
readonly setupDocsUrl?: string;
/**
* Display name of the provider this agent can sign in to in-app, e.g.
* `'ChatGPT'`; absent means it has no such flow. A proper noun the workbench
* cannot invent, so it crosses the wire like `displayName` does and is
* interpolated into a localized template rather than shown raw.
*/
readonly signInProviderName?: string;
}

/** A request the workbench addresses to one agent, made unique so a repeat press is not swallowed. */
export interface IAgentSdkSetupRequest {
readonly agent: string;
readonly request: string;
}

export function isAgentSdkSetupRequestFor(value: unknown, agent: string): value is IAgentSdkSetupRequest {
if (!value || typeof value !== 'object') {
return false;
}
const request: Partial<IAgentSdkSetupRequest> = value;
return request.agent === agent && typeof request.request === 'string' && request.request.length > 0;
}

function readOne(value: unknown, agent: string): IAgentSdkSetupInfo | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const info: Partial<IAgentSdkSetupInfo> = value;
if (info.download !== 'notDownloaded' && info.download !== 'downloading' && info.download !== 'ready') {
return undefined;
}
return {
agent,
download: info.download,
setupDocsUrl: typeof info.setupDocsUrl === 'string' ? info.setupDocsUrl : undefined,
signInProviderName: typeof info.signInProviderName === 'string' && info.signInProviderName.length > 0 ? info.signInProviderName : undefined,
};
}

/**
* Every agent that has published a setup status, in root-state key order. Agents
* that have not published are absent rather than guessed at — there is no honest
* default for "we were never told".
*/
export function readAgentSdkSetupInfos(state: RootState | undefined): readonly IAgentSdkSetupInfo[] {
// The one sanctioned hop into the namespaced setup slots; every field read out
// of them is validated in `readOne`.
const meta = state?._meta;
const values = state?.config?.values;
const infos: IAgentSdkSetupInfo[] = [];
const seen = new Set<string>();
for (const bag of [values, meta]) {
for (const key of Object.keys(bag ?? {})) {
if (!key.startsWith(AGENT_SDK_SETUP_STATUS_KEY_PREFIX)) {
continue;
}
const agent = key.slice(AGENT_SDK_SETUP_STATUS_KEY_PREFIX.length);
if (!agent || seen.has(agent)) {
continue;
}
const info = readOne(bag?.[key], agent);
if (info) {
seen.add(agent);
infos.push(info);
}
}
}
return infos;
}

/**
* The agents whose SDK the user has agreed to fetch, decoded from storage. A
* malformed or absent record reads as "nobody consented", which costs at worst
* one extra press of a button the user was about to press anyway.
*/
export function readConsentedSdkAgents(stored: string | undefined): ReadonlySet<string> {
if (!stored) {
return new Set();
}
try {
const parsed: unknown = JSON.parse(stored);
return new Set(Array.isArray(parsed) ? parsed.filter(agent => typeof agent === 'string') : []);
} catch {
return new Set();
}
}

export function writeConsentedSdkAgents(agents: ReadonlySet<string>): string {
return JSON.stringify([...agents]);
}

/**
* Which agents should be asked to fetch their SDK without being offered a
* button, given standing consent. The SDK version is pinned per build
* and the cache keyed by version, so every update invalidates it — daily on
* Insiders. Consent is to "this product downloads the Claude SDK", not to one
* tarball, so re-asking would nag people who already said yes.
*
* It does not carry to a *different* agent: the button says "we need to
* download the Codex Agent SDK", and pressing it is not permission to fetch
* Claude's.
*
* `alreadyRequested` stops a failing download retrying forever: a failed fetch
* republishes `notDownloaded`, and every status change re-runs this. A window is
* the retry unit.
*/
export function resolveConsentedSdkDownloads(
consentedAgents: ReadonlySet<string>,
setups: readonly IAgentSdkSetupInfo[],
alreadyRequested: ReadonlySet<string>,
): readonly string[] {
return setups
.filter(setup => setup.download === 'notDownloaded' && consentedAgents.has(setup.agent) && !alreadyRequested.has(setup.agent))
.map(setup => setup.agent);
}
121 changes: 121 additions & 0 deletions src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ILogService } from '../../log/common/log.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import type { IAgentSdkDownloadProgress } from './agentSdkDownloader.js';

// #region Failure classification

/**
* Coarse bucket for a failed SDK fetch. A closed set: the downloader's own error
* strings carry the CDN URL and the cache path, so the raw message can never be
* the reported reason. `notConfigured` and `unsupportedTarget` describe a build
* that cannot fetch this SDK at all, so a non-zero count is a signal in itself.
*/
export type AgentSdkDownloadFailureReason =
| 'cancelled'
| 'network'
| 'filesystem'
| 'extract'
| 'notConfigured'
| 'unsupportedTarget'
| 'unknown';

/**
* Order matters. Network before extraction, because an HTTP failure message
* embeds the tarball URL and would otherwise match an archive-shaped hint;
* filesystem errnos before network, because they are unambiguous where a bare
* `EACCES` from a proxy is not.
*/
const FAILURE_HINTS: readonly (readonly [AgentSdkDownloadFailureReason, readonly string[]])[] = [
['notConfigured', ['no `product.agentSdks', 'unknown placeholder']],
['unsupportedTarget', ['no SDK target for this host']],
['filesystem', ['ENOSPC', 'EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EMFILE', 'ENAMETOOLONG', 'EXDEV']],
['network', ['HTTP ', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPROTO', 'ECONNABORTED', 'socket hang up', 'certificate', 'tunneling socket', 'getaddrinfo']],
['extract', ['TAR_', 'zlib', 'gzip', 'unexpected end of file', 'incorrect header check', 'invalid entry']],
];

/**
* Bucket a downloader failure message. Substring matching, because the messages
* are assembled from Node errnos, `node-tar` diagnostics and our own wrappers —
* none of which carry a stable code by the time they arrive here. Anything
* unrecognised is `unknown` rather than guessed at: a rising `unknown` share is
* the signal to add a hint, which a neighbouring bucket would hide.
*/
export function classifyAgentSdkDownloadFailure(error: string | undefined): AgentSdkDownloadFailureReason {
if (!error) {
return 'unknown';
}
// The downloader reports cancellation as this exact token, not as a message.
if (error === 'cancelled') {
return 'cancelled';
}
const haystack = error.toLowerCase();
for (const [reason, hints] of FAILURE_HINTS) {
if (hints.some(hint => haystack.includes(hint.toLowerCase()))) {
return reason;
}
}
return 'unknown';
}

// #endregion

// #region Telemetry

interface IAgentSdkDownloadEvent {
packageId: string;
phase: string;
failureReason: string;
explicitlyRequested: boolean;
durationMs: number;
receivedBytes: number;
totalBytes: number;
}

type AgentSdkDownloadClassification = {
packageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which agent SDK was being fetched, e.g. claude or codex.' };
phase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the download started, completed, or failed.' };
failureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Coarse bucket for a failed download (cancelled, network, filesystem, extract, notConfigured, unsupportedTarget, unknown). Empty unless the phase is failed.' };
explicitlyRequested: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setup flow drove this download and showed its progress — a click, or a quiet re-fetch under standing consent — as opposed to a background fetch nobody was watching.' };
durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the download had been running when it reached this phase.' };
receivedBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Bytes fetched by the time this phase was reached.' };
totalBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total size the server advertised, or zero when it did not.' };
owner: 'TylerLeonhardt';
comment: 'The middle of the agent SDK setup funnel: whether an offered download is actually attempted, and whether it works.';
};

/**
* Report one endpoint of a download. Callers pass only terminal and `started`
* frames; the throttled `progress` frames are not counted.
* `explicitlyRequested` splits setup-driven downloads from background ones, not
* clicks from standing consent — both hold a progress interest. That split is
* the funnel's own `downloadClicked` / `consentedDownload` steps.
*/
export function reportAgentSdkDownload(
telemetryService: ITelemetryService,
logService: ILogService,
progress: IAgentSdkDownloadProgress,
durationMs: number,
): void {
const failureReason = progress.phase === 'failed' ? classifyAgentSdkDownloadFailure(progress.error) : '';
telemetryService.publicLog2<IAgentSdkDownloadEvent, AgentSdkDownloadClassification>('agentHost.agentSdkDownload', {
packageId: progress.packageId,
phase: progress.phase,
failureReason,
explicitlyRequested: progress.explicitlyRequested,
durationMs,
receivedBytes: progress.receivedBytes,
totalBytes: progress.totalBytes ?? 0,
});
logService.info(
`[AgentSdkDownloader] ${progress.packageId}: ${progress.phase}`
+ ` (explicit=${progress.explicitlyRequested}, bytes=${progress.receivedBytes}/${progress.totalBytes ?? 'unknown'}, ms=${durationMs}`
+ `${failureReason ? `, reason=${failureReason}` : ''})`,
);
}

// #endregion
Loading
Loading