Skip to content

Commit 05aadf1

Browse files
authored
updatedInput for PreToolUse (microsoft#293575)
1 parent 0ed85cc commit 05aadf1

6 files changed

Lines changed: 321 additions & 4 deletions

File tree

src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js';
2323
import { localize, localize2 } from '../../../../../nls.js';
2424
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
2525
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
26+
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
2627
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
2728
import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
2829
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
@@ -122,6 +123,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
122123
@IStorageService private readonly _storageService: IStorageService,
123124
@ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService,
124125
@IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService,
126+
@ICommandService private readonly _commandService: ICommandService,
125127
) {
126128
super();
127129

@@ -422,6 +424,38 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
422424
return { hookResult };
423425
}
424426

427+
/**
428+
* Validate updatedInput from a preToolUse hook against the tool's input schema
429+
* using the json.validate command from the JSON extension.
430+
* @returns An error message string if validation fails, or undefined if valid.
431+
*/
432+
private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise<string | undefined> {
433+
if (!toolData?.inputSchema) {
434+
return undefined;
435+
}
436+
437+
type JsonDiagnostic = {
438+
message: string;
439+
range: { line: number; character: number }[];
440+
severity: string;
441+
code?: string | number;
442+
};
443+
444+
try {
445+
const schemaUri = createToolSchemaUri(toolId);
446+
const inputJson = JSON.stringify(updatedInput);
447+
const diagnostics = await this._commandService.executeCommand<JsonDiagnostic[]>('json.validate', schemaUri, inputJson) || [];
448+
if (diagnostics.length > 0) {
449+
return diagnostics.map(d => d.message).join('; ');
450+
}
451+
} catch (e) {
452+
// json extension may not be available; skip validation
453+
this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`);
454+
}
455+
456+
return undefined;
457+
}
458+
425459
/**
426460
* Execute the postToolUse hook after tool completion.
427461
* If the hook returns a "block" decision, additional context is appended to the tool result
@@ -517,6 +551,17 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo
517551
return hookDenialResult;
518552
}
519553

554+
// Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema
555+
if (preToolUseHookResult?.updatedInput) {
556+
const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput);
557+
if (validationError) {
558+
this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`);
559+
} else {
560+
this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`);
561+
dto.parameters = preToolUseHookResult.updatedInput;
562+
}
563+
}
564+
520565
// Fire the event to notify listeners that a tool is being invoked
521566
this._onDidInvokeTool.fire({
522567
toolId: dto.toolId,

src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export interface IPreToolUseCommandOutput extends IHookCommandOutput {
8888
readonly hookEventName?: string;
8989
readonly permissionDecision?: 'allow' | 'deny';
9090
readonly permissionDecisionReason?: string;
91+
readonly updatedInput?: object;
9192
readonly additionalContext?: string;
9293
};
9394
}

src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ export class HooksExecutionService extends Disposable implements IHooksExecution
390390
let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined;
391391
let winningResult: IHookResult | undefined;
392392
let winningReason: string | undefined;
393+
let lastUpdatedInput: object | undefined;
393394

394395
for (const result of results) {
395396
if (result.success && typeof result.output === 'object' && result.output !== null) {
@@ -408,6 +409,11 @@ export class HooksExecutionService extends Disposable implements IHooksExecution
408409
allAdditionalContext.push(hookSpecificOutput.additionalContext);
409410
}
410411

412+
// Track the last updatedInput (later hooks override earlier ones)
413+
if (hookSpecificOutput.updatedInput) {
414+
lastUpdatedInput = hookSpecificOutput.updatedInput;
415+
}
416+
411417
// Track the most restrictive decision: deny > ask > allow
412418
const decision = hookSpecificOutput.permissionDecision;
413419
if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) {
@@ -422,14 +428,16 @@ export class HooksExecutionService extends Disposable implements IHooksExecution
422428
}
423429
}
424430

425-
if (!mostRestrictiveDecision || !winningResult) {
431+
if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {
426432
return undefined;
427433
}
428434

435+
const baseResult = winningResult ?? results[0];
429436
return {
430-
...winningResult,
437+
...baseResult,
431438
permissionDecision: mostRestrictiveDecision,
432439
permissionDecisionReason: winningReason,
440+
updatedInput: lastUpdatedInput,
433441
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
434442
};
435443
}

src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* External types (in hooksCommandTypes.ts) define the contract with spawned commands.
1717
*/
1818

19-
import { vEnum, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js';
19+
import { vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js';
2020

2121
//#region Common Hook Types
2222

@@ -67,8 +67,9 @@ export interface IPreToolUseCallerInput {
6767
export const preToolUseOutputValidator = vObj({
6868
hookSpecificOutput: vOptionalProp(vObj({
6969
hookEventName: vOptionalProp(vString()),
70-
permissionDecision: vEnum('allow', 'deny', 'ask'),
70+
permissionDecision: vOptionalProp(vEnum('allow', 'deny', 'ask')),
7171
permissionDecisionReason: vOptionalProp(vString()),
72+
updatedInput: vOptionalProp(vObjAny()),
7273
additionalContext: vOptionalProp(vString()),
7374
})),
7475
});
@@ -88,6 +89,12 @@ export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask';
8889
export interface IPreToolUseHookResult extends IHookResult {
8990
readonly permissionDecision?: PreToolUsePermissionDecision;
9091
readonly permissionDecisionReason?: string;
92+
/**
93+
* Modified tool input parameters from the hook.
94+
* When set, replaces the original tool input before execution.
95+
* Combine with 'allow' to auto-approve, or 'ask' to show modified input to the user.
96+
*/
97+
readonly updatedInput?: object;
9198
readonly additionalContext?: string[];
9299
}
93100

src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/
1414
import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js';
1515
import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js';
1616
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
17+
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
1718
import { ConfigurationTarget, IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js';
1819
import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js';
1920
import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js';
@@ -147,6 +148,7 @@ interface TestToolsServiceOptions {
147148
accessibilitySignalService?: Partial<IAccessibilitySignalService>;
148149
telemetryService?: Partial<ITelemetryService>;
149150
hooksExecutionService?: MockHooksExecutionService;
151+
commandService?: Partial<ICommandService>;
150152
/** Called after configurationService is created but before the service is instantiated */
151153
configureServices?: (config: TestConfigurationService) => void;
152154
}
@@ -181,6 +183,9 @@ function createTestToolsService(store: ReturnType<typeof ensureNoDisposablesAreL
181183
if (options?.telemetryService) {
182184
instaService.stub(ITelemetryService, options.telemetryService);
183185
}
186+
if (options?.commandService) {
187+
instaService.stub(ICommandService, options.commandService as ICommandService);
188+
}
184189

185190
const service = store.add(instaService.createInstance(LanguageModelToolsService));
186191
return { configurationService, chatService, service, contextKeyService };
@@ -4037,6 +4042,152 @@ suite('LanguageModelToolsService', () => {
40374042
assert.strictEqual(result.content[0].kind, 'text');
40384043
assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success');
40394044
});
4045+
4046+
test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => {
4047+
let receivedParameters: Record<string, any> | undefined;
4048+
mockHooksService.preToolUseHookResult = {
4049+
output: undefined,
4050+
success: true,
4051+
permissionDecision: 'allow',
4052+
updatedInput: { safeCommand: 'echo hello' },
4053+
};
4054+
4055+
const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', {
4056+
invoke: async (dto) => {
4057+
receivedParameters = dto.parameters;
4058+
return { content: [{ kind: 'text', value: 'done' }] };
4059+
},
4060+
prepareToolInvocation: async () => ({
4061+
confirmationMessages: {
4062+
title: 'Confirm?',
4063+
message: 'Confirm action',
4064+
allowAutoConfirm: true
4065+
}
4066+
})
4067+
});
4068+
4069+
stubGetSession(hookChatService, 'hook-test-updated-input', { requestId: 'req1' });
4070+
4071+
await hookService.invokeTool(
4072+
tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }),
4073+
async () => 0,
4074+
CancellationToken.None
4075+
);
4076+
4077+
assert.deepStrictEqual(receivedParameters, { safeCommand: 'echo hello' });
4078+
});
4079+
4080+
test('when hook returns updatedInput that fails schema validation, original parameters are kept', async () => {
4081+
const mockCommandService = {
4082+
executeCommand: async (commandId: string) => {
4083+
if (commandId === 'json.validate') {
4084+
return [{ message: 'Missing required property "command"', range: [{ line: 0, character: 0 }, { line: 0, character: 1 }], severity: 'Error' }];
4085+
}
4086+
return undefined;
4087+
}
4088+
};
4089+
4090+
const mockHooks = new MockHooksExecutionService();
4091+
const setup = createTestToolsService(store, {
4092+
hooksExecutionService: mockHooks,
4093+
commandService: mockCommandService as ICommandService,
4094+
});
4095+
4096+
let receivedParameters: Record<string, any> | undefined;
4097+
mockHooks.preToolUseHookResult = {
4098+
output: undefined,
4099+
success: true,
4100+
permissionDecision: 'allow',
4101+
updatedInput: { invalidField: 'wrong' },
4102+
};
4103+
4104+
const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', {
4105+
invoke: async (dto) => {
4106+
receivedParameters = dto.parameters;
4107+
return { content: [{ kind: 'text', value: 'done' }] };
4108+
},
4109+
prepareToolInvocation: async () => ({
4110+
confirmationMessages: {
4111+
title: 'Confirm?',
4112+
message: 'Confirm action',
4113+
allowAutoConfirm: true
4114+
}
4115+
})
4116+
}, {
4117+
inputSchema: {
4118+
type: 'object',
4119+
properties: { command: { type: 'string' } },
4120+
required: ['command'],
4121+
}
4122+
});
4123+
4124+
stubGetSession(setup.chatService, 'hook-test-validation-fail', { requestId: 'req1' });
4125+
4126+
await setup.service.invokeTool(
4127+
tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }),
4128+
async () => 0,
4129+
CancellationToken.None
4130+
);
4131+
4132+
// Original parameters should be kept since validation failed
4133+
assert.deepStrictEqual(receivedParameters, { command: 'original' });
4134+
});
4135+
4136+
test('when hook returns updatedInput that passes schema validation, parameters are replaced', async () => {
4137+
const mockCommandService = {
4138+
executeCommand: async (commandId: string) => {
4139+
if (commandId === 'json.validate') {
4140+
return []; // no diagnostics = valid
4141+
}
4142+
return undefined;
4143+
}
4144+
};
4145+
4146+
const mockHooks = new MockHooksExecutionService();
4147+
const setup = createTestToolsService(store, {
4148+
hooksExecutionService: mockHooks,
4149+
commandService: mockCommandService as ICommandService,
4150+
});
4151+
4152+
let receivedParameters: Record<string, any> | undefined;
4153+
mockHooks.preToolUseHookResult = {
4154+
output: undefined,
4155+
success: true,
4156+
permissionDecision: 'allow',
4157+
updatedInput: { command: 'safe-command' },
4158+
};
4159+
4160+
const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', {
4161+
invoke: async (dto) => {
4162+
receivedParameters = dto.parameters;
4163+
return { content: [{ kind: 'text', value: 'done' }] };
4164+
},
4165+
prepareToolInvocation: async () => ({
4166+
confirmationMessages: {
4167+
title: 'Confirm?',
4168+
message: 'Confirm action',
4169+
allowAutoConfirm: true
4170+
}
4171+
})
4172+
}, {
4173+
inputSchema: {
4174+
type: 'object',
4175+
properties: { command: { type: 'string' } },
4176+
required: ['command'],
4177+
}
4178+
});
4179+
4180+
stubGetSession(setup.chatService, 'hook-test-validation-pass', { requestId: 'req1' });
4181+
4182+
await setup.service.invokeTool(
4183+
tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }),
4184+
async () => 0,
4185+
CancellationToken.None
4186+
);
4187+
4188+
// Updated parameters should be applied since validation passed
4189+
assert.deepStrictEqual(receivedParameters, { command: 'safe-command' });
4190+
});
40404191
});
40414192

40424193
suite('postToolUse hooks', () => {

0 commit comments

Comments
 (0)