Path: blob/main/src/vs/platform/agentHost/common/agentHostSchema.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../nls.js';6import { SessionConfigKey } from './sessionConfigKeys.js';7import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js';8import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js';910// ---- Schema builder --------------------------------------------------------1112/**13* A schema property with a phantom TypeScript type and a precomputed14* runtime validator.15*16* The `<T>` type parameter is the developer's assertion about the17* property's runtime shape; the validator derived from `protocol`18* (`type`, `enum`, `items`, `properties`, `required`) enforces it at19* runtime.20*/21export interface ISchemaProperty<T> {22readonly protocol: SessionConfigPropertySchema;23/**24* Returns `true` iff `value` conforms to {@link protocol}. Narrows25* the type to `T` for callers. The boolean form is preferred for26* control flow; use {@link assertValid} when you want a descriptive27* error for the offending path.28*/29validate(value: unknown): value is T;30/**31* Throws a {@link ProtocolError} with `JsonRpcErrorCodes.InvalidParams`32* describing the offending path (e.g. `'permissions.allow[2]'`) when33* `value` does not conform to {@link protocol}. Otherwise returns and34* narrows the type to `T`.35*36* @param path Dotted path prefix to embed in error messages. Defaults37* to empty (the value itself).38*/39assertValid(value: unknown, path?: string): asserts value is T;40}4142/**43* Defines a strongly-typed schema property whose runtime validator is44* derived from the supplied JSON-schema descriptor.45*/46export function schemaProperty<T>(protocol: SessionConfigPropertySchema): ISchemaProperty<T> {47const assertFn = buildAssert(protocol);48const assertValid = (value: unknown, path: string = ''): asserts value is T => assertFn(value, path);49const validate = (value: unknown): value is T => {50try {51assertFn(value, '');52return true;53} catch {54return false;55}56};57return { protocol, validate, assertValid };58}5960// eslint-disable-next-line @typescript-eslint/no-explicit-any61export type SchemaDefinition = Record<string, ISchemaProperty<any>>;6263export type SchemaValue<P> = P extends ISchemaProperty<infer T> ? T : never;6465export type SchemaValues<D extends SchemaDefinition> = {66[K in keyof D]?: SchemaValue<D[K]>;67};6869/**70* A bundle of named schema properties plus helpers for serializing to the71* protocol shape, validating a values bag at write sites, and validating72* a single key at read sites.73*/74export interface ISchema<D extends SchemaDefinition> {75readonly definition: D;76/** Returns the protocol-serializable schema for this bundle. */77toProtocol(): SessionConfigSchema;78/**79* Validates each known key in `values` against its schema and returns80* a new plain record. Throws a {@link ProtocolError} with a path like81* `'permissions.allow[2]'` when any supplied value fails validation.82* Unknown keys are passed through untouched for forward-compatibility.83*/84values(values: SchemaValues<D>): Record<string, unknown>;85/**86* Returns `true` iff `value` validates against the schema for `key`.87* Unknown keys return `false`.88*/89validate<K extends keyof D & string>(key: K, value: unknown): value is SchemaValue<D[K]>;90/**91* Throws a {@link ProtocolError} describing the offending path when92* `value` does not validate against the schema for `key`, or when93* `key` is not defined in the schema.94*/95assertValid<K extends keyof D & string>(key: K, value: unknown): asserts value is SchemaValue<D[K]>;96/**97* Returns a fully-typed values bag by validating each key of the98* schema against `values` and falling back to the default when99* the incoming value is missing or fails validation.100*101* Semantics: for every key declared in the schema `definition`:102* - if `values[key]` validates, it is kept;103* - else if `key` is present in `defaults`, the default is used;104* - else the key is omitted from the result.105*106* This means callers MAY supply defaults for only a subset of the107* schema — keys not present in `defaults` are simply left unset108* when the incoming value is missing or invalid. This is useful109* when some properties (e.g. per-session `permissions`) should be110* inherited from a higher scope rather than materialized on every111* new session.112*113* Intended for sanitizing untrusted input at protocol boundaries114* (e.g. `resolveSessionConfig`). Keys that fail validation are115* silently replaced with their default or dropped; use116* {@link values} or {@link assertValid} when you want a descriptive117* {@link ProtocolError} instead.118*/119validateOrDefault<T extends Partial<{ [K in keyof D]: SchemaValue<D[K]> }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T;120}121122export function createSchema<D extends SchemaDefinition>(definition: D): ISchema<D> {123return {124definition,125toProtocol(): SessionConfigSchema {126const properties: Record<string, SessionConfigPropertySchema> = {};127for (const key of Object.keys(definition)) {128properties[key] = definition[key].protocol;129}130return { type: 'object', properties };131},132values(values) {133const raw = values as Record<string, unknown>;134for (const key of Object.keys(definition)) {135const value = raw[key];136if (value === undefined) {137continue;138}139// Local with explicit annotation so TypeScript accepts the140// assertion-signature call (per TS4104).141const prop: ISchemaProperty<unknown> = definition[key];142prop.assertValid(value, key);143}144return { ...raw };145},146validate<K extends keyof D & string>(key: K, value: unknown): value is SchemaValue<D[K]> {147const prop = definition[key];148return prop ? prop.validate(value) : false;149},150assertValid<K extends keyof D & string>(key: K, value: unknown): asserts value is SchemaValue<D[K]> {151const prop: ISchemaProperty<unknown> | undefined = definition[key];152if (!prop) {153throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unknown schema key '${key}'`);154}155// Re-bind post-narrowing to keep the call target explicitly typed156// (required for assertion-signature calls, TS4104).157const narrowed: ISchemaProperty<unknown> = prop;158narrowed.assertValid(value, key);159},160validateOrDefault<T extends Partial<{ [K in keyof D]: SchemaValue<D[K]> }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T {161const result: Record<string, unknown> = {};162const raw: { [K in keyof T]?: unknown } = values ?? {};163for (const key of Object.keys(definition)) {164const prop = definition[key];165const candidate = raw[key];166if (candidate !== undefined && prop.validate(candidate)) {167result[key] = candidate;168} else if (Object.prototype.hasOwnProperty.call(defaults, key)) {169result[key] = (defaults as Record<string, unknown>)[key];170}171// else: key not in defaults and incoming value missing/invalid172// → leave unset so higher-scope defaults can fill in.173}174return result as T;175},176};177}178179// ---- Validator derivation --------------------------------------------------180181/**182* A validator that throws a {@link ProtocolError} annotated with the183* offending path when `value` does not conform, or returns normally184* when it does.185*/186type AssertValidator = (value: unknown, path: string) => void;187188function buildAssert(schema: SessionConfigPropertySchema): AssertValidator {189if (schema.type === 'object' && schema.properties) {190const propAsserts: Record<string, AssertValidator> = {};191for (const key of Object.keys(schema.properties)) {192propAsserts[key] = buildAssert(schema.properties[key] as SessionConfigPropertySchema);193}194const required = new Set(schema.required ?? []);195return (value, path) => {196if (typeof value !== 'object' || value === null || Array.isArray(value)) {197throw invalidParams(path, 'object', value);198}199const obj = value as Record<string, unknown>;200for (const key of Object.keys(propAsserts)) {201const childPath = joinPath(path, key);202if (obj[key] === undefined) {203if (required.has(key)) {204throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Missing required property at '${childPath}'`);205}206continue;207}208propAsserts[key](obj[key], childPath);209}210};211}212if (schema.type === 'array' && schema.items) {213const itemAssert = buildAssert(schema.items as SessionConfigPropertySchema);214return (value, path) => {215if (!Array.isArray(value)) {216throw invalidParams(path, 'array', value);217}218for (let i = 0; i < value.length; i++) {219itemAssert(value[i], `${path}[${i}]`);220}221};222}223return buildPrimitiveAssert(schema);224}225226function buildPrimitiveAssert(schema: SessionConfigPropertySchema): AssertValidator {227const enumDynamic = schema.enumDynamic === true;228return (value, path) => {229switch (schema.type) {230case 'string': if (typeof value !== 'string') { throw invalidParams(path, 'string', value); } break;231case 'number': if (typeof value !== 'number') { throw invalidParams(path, 'number', value); } break;232case 'boolean': if (typeof value !== 'boolean') { throw invalidParams(path, 'boolean', value); } break;233case 'array': if (!Array.isArray(value)) { throw invalidParams(path, 'array', value); } break;234case 'object': if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw invalidParams(path, 'object', value); } break;235}236if (schema.enum && !enumDynamic && !schema.enum.includes(value as string)) {237throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || '<root>'}': ${safeStringify(value)} is not one of [${schema.enum.map(v => JSON.stringify(v)).join(', ')}]`);238}239};240}241242function invalidParams(path: string, expected: string, value: unknown): ProtocolError {243return new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || '<root>'}': expected ${expected}, got ${safeStringify(value)}`);244}245246function joinPath(parent: string, key: string): string {247return parent ? `${parent}.${key}` : key;248}249250function safeStringify(value: unknown): string {251try {252return JSON.stringify(value);253} catch {254return String(value);255}256}257258// ---- Platform-owned schema -------------------------------------------------259260export type AutoApproveLevel = 'default' | 'autoApprove' | 'autopilot';261262export type SessionMode = 'interactive' | 'plan';263264export interface IPermissionsValue {265readonly allow: readonly string[];266readonly deny: readonly string[];267}268269const permissionsProperty = schemaProperty<IPermissionsValue>({270type: 'object',271title: localize('agentHost.sessionConfig.permissions', "Permissions"),272description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."),273properties: {274allow: {275type: 'array',276title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"),277items: {278type: 'string',279title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),280},281},282deny: {283type: 'array',284title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"),285items: {286type: 'string',287title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),288},289},290},291default: { allow: [], deny: [] },292sessionMutable: true,293});294295/**296* Session-config properties owned by the platform itself — i.e. consumed297* by the agent host rather than by any particular agent.298*299* Agents extend this schema by spreading `platformSessionSchema.definition`300* into their own {@link createSchema} call together with any301* provider-specific properties.302*/303export const platformSessionSchema = createSchema({304[SessionConfigKey.AutoApprove]: schemaProperty<AutoApproveLevel>({305type: 'string',306title: localize('agentHost.sessionConfig.autoApprove', "Approvals"),307description: localize('agentHost.sessionConfig.autoApproveDescription', "Tool approval behavior for this session"),308enum: ['default', 'autoApprove', 'autopilot'],309enumLabels: [310localize('agentHost.sessionConfig.autoApprove.default', "Default Approvals"),311localize('agentHost.sessionConfig.autoApprove.bypass', "Bypass Approvals"),312localize('agentHost.sessionConfig.autoApprove.autopilot', "Autopilot (Preview)"),313],314enumDescriptions: [315localize('agentHost.sessionConfig.autoApprove.defaultDescription', "Copilot uses your configured settings"),316localize('agentHost.sessionConfig.autoApprove.bypassDescription', "All tool calls are auto-approved"),317localize('agentHost.sessionConfig.autoApprove.autopilotDescription', "Autonomously iterates from start to finish"),318],319default: 'default',320sessionMutable: true,321}),322[SessionConfigKey.Permissions]: permissionsProperty,323[SessionConfigKey.Mode]: schemaProperty<SessionMode>({324type: 'string',325title: localize('agentHost.sessionConfig.mode', "Agent Mode"),326description: localize('agentHost.sessionConfig.modeDescription', "How the agent should approach this turn"),327enum: ['interactive', 'plan'],328enumLabels: [329localize('agentHost.sessionConfig.mode.interactive', "Interactive"),330localize('agentHost.sessionConfig.mode.plan', "Plan"),331],332enumDescriptions: [333localize('agentHost.sessionConfig.mode.interactiveDescription', "Ask for input and approval for each action"),334localize('agentHost.sessionConfig.mode.planDescription', "Generate a plan first, then choose how to execute it"),335],336default: 'interactive',337sessionMutable: true,338}),339});340341/**342* Root (agent host) config properties owned by the platform itself.343*344* Root config acts as the baseline that applies to every session:345*346* - {@link SessionConfigKey.Permissions} — host-wide allow/deny lists347* unioned with each session's own permissions when evaluating tool348* auto-approval. See `SessionPermissionManager` for the evaluation349* rules.350*/351export const platformRootSchema = createSchema({352[SessionConfigKey.Permissions]: permissionsProperty,353});354355356