Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts
5241 views
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 { CancellationToken } from '../../../../../base/common/cancellation.js';
7
import { Emitter, Event } from '../../../../../base/common/event.js';
8
import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
9
import { StopWatch } from '../../../../../base/common/stopwatch.js';
10
import { URI, isUriComponents } from '../../../../../base/common/uri.js';
11
import { localize } from '../../../../../nls.js';
12
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
13
import { ILogService } from '../../../../../platform/log/common/log.js';
14
import { Registry } from '../../../../../platform/registry/common/platform.js';
15
import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js';
16
import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js';
17
import {
18
HookCommandResultKind,
19
IHookCommandInput,
20
IHookCommandResult,
21
IPostToolUseCommandInput,
22
IPreToolUseCommandInput
23
} from './hooksCommandTypes.js';
24
import {
25
commonHookOutputValidator,
26
IHookResult,
27
IPostToolUseCallerInput,
28
IPostToolUseHookResult,
29
IPreToolUseCallerInput,
30
IPreToolUseHookResult,
31
postToolUseOutputValidator,
32
PreToolUsePermissionDecision,
33
preToolUseOutputValidator
34
} from './hooksTypes.js';
35
36
export const hooksOutputChannelId = 'hooksExecution';
37
const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks");
38
39
export interface IHooksExecutionOptions {
40
readonly input?: unknown;
41
readonly token?: CancellationToken;
42
}
43
44
export interface IHookExecutedEvent {
45
readonly hookType: HookTypeValue;
46
readonly sessionResource: URI;
47
readonly input: unknown;
48
readonly results: readonly IHookResult[];
49
readonly durationMs: number;
50
}
51
52
/**
53
* Callback interface for hook execution proxies.
54
* MainThreadHooks implements this to forward calls to the extension host.
55
*/
56
export interface IHooksExecutionProxy {
57
runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult>;
58
}
59
60
export const IHooksExecutionService = createDecorator<IHooksExecutionService>('hooksExecutionService');
61
62
export interface IHooksExecutionService {
63
_serviceBrand: undefined;
64
65
/**
66
* Fires when a hook has finished executing.
67
*/
68
readonly onDidExecuteHook: Event<IHookExecutedEvent>;
69
70
/**
71
* Called by mainThreadHooks when extension host is ready
72
*/
73
setProxy(proxy: IHooksExecutionProxy): void;
74
75
/**
76
* Register hooks for a session. Returns a disposable that unregisters them.
77
*/
78
registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable;
79
80
/**
81
* Get hooks registered for a session.
82
*/
83
getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined;
84
85
/**
86
* Execute hooks of the given type for the given session
87
*/
88
executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]>;
89
90
/**
91
* Execute preToolUse hooks with typed input and validated output.
92
* The execution service builds the full hook input from the caller input plus session context.
93
* Returns a combined result with common fields and permission decision.
94
*/
95
executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise<IPreToolUseHookResult | undefined>;
96
97
/**
98
* Execute postToolUse hooks with typed input and validated output.
99
* Called after a tool completes successfully. The execution service builds the full hook input
100
* from the caller input plus session context.
101
* Returns a combined result with decision and additional context.
102
*/
103
executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise<IPostToolUseHookResult | undefined>;
104
}
105
106
/**
107
* Keys that should be redacted when logging hook input.
108
*/
109
const redactedInputKeys = ['toolArgs'];
110
111
export class HooksExecutionService extends Disposable implements IHooksExecutionService {
112
declare readonly _serviceBrand: undefined;
113
114
private readonly _onDidExecuteHook = this._register(new Emitter<IHookExecutedEvent>());
115
readonly onDidExecuteHook: Event<IHookExecutedEvent> = this._onDidExecuteHook.event;
116
117
private _proxy: IHooksExecutionProxy | undefined;
118
private readonly _sessionHooks = new Map<string, IChatRequestHooks>();
119
/** Stored transcript path per session (keyed by session URI string). */
120
private readonly _sessionTranscriptPaths = new Map<string, URI>();
121
private _channelRegistered = false;
122
private _requestCounter = 0;
123
124
constructor(
125
@ILogService private readonly _logService: ILogService,
126
@IOutputService private readonly _outputService: IOutputService,
127
) {
128
super();
129
}
130
131
setProxy(proxy: IHooksExecutionProxy): void {
132
this._proxy = proxy;
133
}
134
135
private _ensureOutputChannel(): void {
136
if (this._channelRegistered) {
137
return;
138
}
139
Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({
140
id: hooksOutputChannelId,
141
label: hooksOutputChannelLabel,
142
log: false
143
});
144
this._channelRegistered = true;
145
}
146
147
private _log(requestId: number, hookType: HookTypeValue, message: string): void {
148
this._ensureOutputChannel();
149
const channel = this._outputService.getChannel(hooksOutputChannelId);
150
if (channel) {
151
channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`);
152
}
153
}
154
155
private _redactForLogging(input: object): object {
156
const result: Record<string, unknown> = { ...input };
157
for (const key of redactedInputKeys) {
158
if (Object.hasOwn(result, key)) {
159
result[key] = '...';
160
}
161
}
162
return result;
163
}
164
165
/**
166
* JSON.stringify replacer that converts URI / UriComponents values to their string form.
167
*/
168
private readonly _uriReplacer = (_key: string, value: unknown): unknown => {
169
if (URI.isUri(value)) {
170
return value.fsPath;
171
}
172
if (isUriComponents(value)) {
173
return URI.revive(value).fsPath;
174
}
175
return value;
176
};
177
178
private async _runSingleHook(
179
requestId: number,
180
hookType: HookTypeValue,
181
hookCommand: IHookCommand,
182
sessionResource: URI,
183
callerInput: unknown,
184
transcriptPath: URI | undefined,
185
token: CancellationToken
186
): Promise<IHookResult> {
187
// Build the common hook input properties.
188
// URI values are kept as URI objects through the RPC boundary, and converted
189
// to filesystem paths on the extension host side during JSON serialization.
190
const commonInput: IHookCommandInput = {
191
timestamp: new Date().toISOString(),
192
cwd: hookCommand.cwd ?? URI.file(''),
193
sessionId: sessionResource.toString(),
194
hookEventName: hookType,
195
...(transcriptPath ? { transcript_path: transcriptPath } : undefined),
196
};
197
198
// Merge common properties with caller-specific input
199
const fullInput = !!callerInput && typeof callerInput === 'object'
200
? { ...commonInput, ...callerInput }
201
: commonInput;
202
203
const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer);
204
this._log(requestId, hookType, `Running: ${hookCommandJson}`);
205
const inputForLog = this._redactForLogging(fullInput);
206
this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`);
207
208
const sw = StopWatch.create();
209
try {
210
const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token);
211
const result = this._toInternalResult(commandResult);
212
this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed()));
213
return result;
214
} catch (err) {
215
const errMessage = err instanceof Error ? err.message : String(err);
216
this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`);
217
return this._createErrorResult(errMessage);
218
}
219
}
220
221
private _createErrorResult(errorMessage: string): IHookResult {
222
return {
223
resultKind: 'error',
224
output: errorMessage,
225
};
226
}
227
228
private _toInternalResult(commandResult: IHookCommandResult): IHookResult {
229
switch (commandResult.kind) {
230
case HookCommandResultKind.Error: {
231
// Blocking error - shown to model
232
return this._createErrorResult(
233
typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result)
234
);
235
}
236
case HookCommandResultKind.NonBlockingError: {
237
// Non-blocking error - shown to user only as warning
238
const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
239
return {
240
resultKind: 'warning',
241
output: undefined,
242
warningMessage: errorMessage,
243
};
244
}
245
case HookCommandResultKind.Success: {
246
// For string results, no common fields to extract
247
if (typeof commandResult.result !== 'object') {
248
return {
249
resultKind: 'success',
250
output: commandResult.result,
251
};
252
}
253
254
// Extract and validate common fields
255
const validationResult = commonHookOutputValidator.validate(commandResult.result);
256
const commonFields = validationResult.error ? {} : validationResult.content;
257
258
// Extract only known hook-specific fields for output
259
const resultObj = commandResult.result as Record<string, unknown>;
260
const hookOutput = this._extractHookSpecificOutput(resultObj);
261
262
return {
263
resultKind: 'success',
264
stopReason: commonFields.stopReason,
265
warningMessage: commonFields.systemMessage,
266
output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,
267
};
268
}
269
default: {
270
// Unexpected result kind - treat as blocking error
271
return this._createErrorResult(`Unexpected hook command result kind: ${commandResult.kind}`);
272
}
273
}
274
}
275
276
/**
277
* Extract hook-specific output fields, excluding common fields.
278
*/
279
private _extractHookSpecificOutput(result: Record<string, unknown>): Record<string, unknown> {
280
const commonFields = new Set(['stopReason', 'systemMessage']);
281
const output: Record<string, unknown> = {};
282
for (const [key, value] of Object.entries(result)) {
283
if (value !== undefined && !commonFields.has(key)) {
284
output[key] = value;
285
}
286
}
287
288
return output;
289
}
290
291
private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void {
292
const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success'
293
: result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError'
294
: 'Error';
295
const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
296
const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]';
297
if (hasOutput) {
298
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`);
299
this._log(requestId, hookType, `Output: ${resultStr}`);
300
} else {
301
this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`);
302
}
303
}
304
305
/**
306
* Extract `transcript_path` from hook input if present.
307
* The caller (e.g. SessionStart) may include it as a URI in the input object.
308
*/
309
private _extractTranscriptPath(input: unknown): URI | undefined {
310
if (typeof input !== 'object' || input === null) {
311
return undefined;
312
}
313
const transcriptPath = (input as Record<string, unknown>)['transcriptPath'];
314
if (URI.isUri(transcriptPath)) {
315
return transcriptPath;
316
}
317
if (isUriComponents(transcriptPath)) {
318
return URI.revive(transcriptPath);
319
}
320
return undefined;
321
}
322
323
registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable {
324
const key = sessionResource.toString();
325
this._sessionHooks.set(key, hooks);
326
return toDisposable(() => {
327
this._sessionHooks.delete(key);
328
this._sessionTranscriptPaths.delete(key);
329
});
330
}
331
332
getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined {
333
return this._sessionHooks.get(sessionResource.toString());
334
}
335
336
async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise<IHookResult[]> {
337
const sw = StopWatch.create();
338
const results: IHookResult[] = [];
339
340
try {
341
if (!this._proxy) {
342
return results;
343
}
344
345
const sessionKey = sessionResource.toString();
346
347
// Extract and store transcript_path from input when present (e.g. SessionStart)
348
const inputTranscriptPath = this._extractTranscriptPath(options?.input);
349
if (inputTranscriptPath) {
350
this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath);
351
}
352
353
const hooks = this.getHooksForSession(sessionResource);
354
if (!hooks) {
355
return results;
356
}
357
358
const hookCommands = hooks[hookType];
359
if (!hookCommands || hookCommands.length === 0) {
360
return results;
361
}
362
363
const transcriptPath = this._sessionTranscriptPaths.get(sessionKey);
364
365
const requestId = this._requestCounter++;
366
const token = options?.token ?? CancellationToken.None;
367
368
this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`);
369
this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`);
370
371
for (const hookCommand of hookCommands) {
372
const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token);
373
results.push(result);
374
375
// If stopReason is set, stop processing remaining hooks
376
if (result.stopReason) {
377
this._log(requestId, hookType, `Stopping: ${result.stopReason}`);
378
break;
379
}
380
}
381
382
return results;
383
} finally {
384
this._onDidExecuteHook.fire({
385
hookType,
386
sessionResource,
387
input: options?.input,
388
results,
389
durationMs: Math.round(sw.elapsed()),
390
});
391
}
392
}
393
394
async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise<IPreToolUseHookResult | undefined> {
395
const toolSpecificInput: IPreToolUseCommandInput = {
396
tool_name: input.toolName,
397
tool_input: input.toolInput,
398
tool_use_id: input.toolCallId,
399
};
400
401
const results = await this.executeHook(HookType.PreToolUse, sessionResource, {
402
input: toolSpecificInput,
403
token: token ?? CancellationToken.None,
404
});
405
406
// Run all hooks and collapse results. Most restrictive decision wins: deny > ask > allow.
407
// Collect all additionalContext strings from every hook.
408
const allAdditionalContext: string[] = [];
409
let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined;
410
let winningResult: IHookResult | undefined;
411
let winningReason: string | undefined;
412
let lastUpdatedInput: object | undefined;
413
414
for (const result of results) {
415
if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) {
416
const validationResult = preToolUseOutputValidator.validate(result.output);
417
if (!validationResult.error) {
418
const hookSpecificOutput = validationResult.content.hookSpecificOutput;
419
if (hookSpecificOutput) {
420
// Validate hookEventName if present - must match the hook type
421
if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) {
422
this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`);
423
continue;
424
}
425
426
// Collect additionalContext from every hook
427
if (hookSpecificOutput.additionalContext) {
428
allAdditionalContext.push(hookSpecificOutput.additionalContext);
429
}
430
431
// Track the last updatedInput (later hooks override earlier ones)
432
if (hookSpecificOutput.updatedInput) {
433
lastUpdatedInput = hookSpecificOutput.updatedInput;
434
}
435
436
// Track the most restrictive decision: deny > ask > allow
437
const decision = hookSpecificOutput.permissionDecision;
438
if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) {
439
mostRestrictiveDecision = decision;
440
winningResult = result;
441
winningReason = hookSpecificOutput.permissionDecisionReason;
442
}
443
}
444
} else {
445
this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`);
446
}
447
}
448
}
449
450
if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {
451
return undefined;
452
}
453
454
const baseResult = winningResult ?? results[0];
455
return {
456
...baseResult,
457
permissionDecision: mostRestrictiveDecision,
458
permissionDecisionReason: winningReason,
459
updatedInput: lastUpdatedInput,
460
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
461
};
462
}
463
464
/**
465
* Returns true if `candidate` is more restrictive than `current`.
466
* Restriction order: deny > ask > allow.
467
*/
468
private _isMoreRestrictive(candidate: PreToolUsePermissionDecision, current: PreToolUsePermissionDecision | undefined): boolean {
469
const order: Record<PreToolUsePermissionDecision, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };
470
return current === undefined || order[candidate] > order[current];
471
}
472
473
async executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise<IPostToolUseHookResult | undefined> {
474
// Check if there are PostToolUse hooks registered before doing any work stringifying tool results
475
const hooks = this.getHooksForSession(sessionResource);
476
const hookCommands = hooks?.[HookType.PostToolUse];
477
if (!hookCommands || hookCommands.length === 0) {
478
return undefined;
479
}
480
481
// Lazily render tool response text only when hooks are registered
482
const toolResponseText = input.getToolResponseText();
483
484
const toolSpecificInput: IPostToolUseCommandInput = {
485
tool_name: input.toolName,
486
tool_input: input.toolInput,
487
tool_response: toolResponseText,
488
tool_use_id: input.toolCallId,
489
};
490
491
const results = await this.executeHook(HookType.PostToolUse, sessionResource, {
492
input: toolSpecificInput,
493
token: token ?? CancellationToken.None,
494
});
495
496
// Run all hooks and collapse results. Block is the most restrictive decision.
497
// Collect all additionalContext strings from every hook.
498
const allAdditionalContext: string[] = [];
499
let hasBlock = false;
500
let blockReason: string | undefined;
501
let blockResult: IHookResult | undefined;
502
503
for (const result of results) {
504
if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) {
505
const validationResult = postToolUseOutputValidator.validate(result.output);
506
if (!validationResult.error) {
507
const validated = validationResult.content;
508
509
// Validate hookEventName if present
510
if (validated.hookSpecificOutput?.hookEventName !== undefined && validated.hookSpecificOutput.hookEventName !== HookType.PostToolUse) {
511
this._logService.warn(`[HooksExecutionService] postToolUse hook returned invalid hookEventName '${validated.hookSpecificOutput.hookEventName}', expected '${HookType.PostToolUse}'`);
512
continue;
513
}
514
515
// Collect additionalContext from every hook
516
if (validated.hookSpecificOutput?.additionalContext) {
517
allAdditionalContext.push(validated.hookSpecificOutput.additionalContext);
518
}
519
520
// Track the first block decision (most restrictive)
521
if (validated.decision === 'block' && !hasBlock) {
522
hasBlock = true;
523
blockReason = validated.reason;
524
blockResult = result;
525
}
526
} else {
527
this._logService.warn(`[HooksExecutionService] postToolUse hook output validation failed: ${validationResult.error.message}`);
528
}
529
}
530
}
531
532
// Return combined result if there's a block decision or any additional context
533
if (!hasBlock && allAdditionalContext.length === 0) {
534
return undefined;
535
}
536
537
const baseResult = blockResult ?? results[0];
538
return {
539
...baseResult,
540
decision: hasBlock ? 'block' : undefined,
541
reason: blockReason,
542
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
543
};
544
}
545
}
546
547