diff --git a/package.json b/package.json index 89a00915e9..eb586e49ed 100644 --- a/package.json +++ b/package.json @@ -3231,6 +3231,14 @@ "onExp" ] }, + "github.copilot.chat.tools.compressOutput.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.tools.compressOutput.enabled%", + "tags": [ + "preview" + ] + }, "github.copilot.chat.backgroundCompaction": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index 3e6198dd14..f1b4444919 100644 --- a/package.nls.json +++ b/package.nls.json @@ -297,6 +297,7 @@ "copilot.tools.viewImage.name": "View Image", "copilot.tools.viewImage.userDescription": "View the contents of an image file", "github.copilot.config.tools.viewImage.enabled": "Enable the view image tool, which allows the agent to view image files such as png, jpg, jpeg, gif, and webp.", + "github.copilot.config.tools.compressOutput.enabled": "(Experimental) Post-process tool output (e.g. `git diff`, `ls -l`, `npm install`) to reduce token usage before it is sent to the model.", "copilot.tools.listDirectory.name": "List Dir", "copilot.tools.listDirectory.userDescription": "List the contents of a directory", "copilot.tools.getTaskOutput.name": "Get Task Output", diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index dc6f0dfcdc..3dd199aa1e 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -139,6 +139,7 @@ import { AgentMemoryService, IAgentMemoryService } from '../../tools/common/agen import { IMemoryCleanupService, MemoryCleanupService } from '../../tools/common/memoryCleanupService'; import { IToolDeferralService } from '../../../platform/networking/common/toolDeferralService'; import { ToolDeferralService } from '../../tools/common/toolDeferralService'; +import { IToolResultCompressor, ToolResultCompressorService } from '../../tools/common/toolResultCompressor'; import { IToolsService } from '../../tools/common/toolsService'; import { ToolsService } from '../../tools/vscode-node/toolsService'; import { LanguageContextServiceImpl } from '../../typescriptContext/vscode-node/languageContextService'; @@ -166,6 +167,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(ITokenizerProvider, new SyncDescriptor(TokenizerProvider, [true])); builder.define(IToolsService, new SyncDescriptor(ToolsService)); builder.define(IToolDeferralService, new ToolDeferralService()); + builder.define(IToolResultCompressor, new SyncDescriptor(ToolResultCompressorService)); builder.define(IAgentMemoryService, new SyncDescriptor(AgentMemoryService)); builder.define(IMemoryCleanupService, new SyncDescriptor(MemoryCleanupService)); builder.define(IChatDiskSessionResources, new SyncDescriptor(ChatDiskSessionResources)); diff --git a/src/extension/tools/common/test/toolResultCompressor.spec.ts b/src/extension/tools/common/test/toolResultCompressor.spec.ts new file mode 100644 index 0000000000..c5461c1ee8 --- /dev/null +++ b/src/extension/tools/common/test/toolResultCompressor.spec.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import type { ILogService } from '../../../../platform/log/common/logService'; +import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import { + LanguageModelDataPart, + LanguageModelPartAudience, + LanguageModelTextPart, + LanguageModelTextPart2, + LanguageModelToolResult, +} from '../../../../vscodeTypes'; +import { IToolResultFilter, ToolResultCompressorService } from '../toolResultCompressor'; + +const TOOL = 'run_in_terminal'; + +function makeService(opts: { + enabled: boolean; + warnings?: string[]; +}): ToolResultCompressorService { + const config = { + getConfig: (_key: unknown) => opts.enabled, + } as unknown as IConfigurationService; + const telemetry = { + sendMSFTTelemetryEvent: () => { /* noop */ }, + } as unknown as ITelemetryService; + const log = { + warn: (msg: string) => { opts.warnings?.push(msg); }, + } as unknown as ILogService; + return new ToolResultCompressorService(config, telemetry, log); +} + +function longText(prefix: string): string { + // Must exceed MIN_COMPRESSIBLE_LENGTH (80) so filters get a chance to run. + return prefix + ' ' + 'x'.repeat(200); +} + +const replaceWithFooFilter: IToolResultFilter = { + id: 'test.replaceWithFoo', + toolNames: [TOOL], + matches: () => true, + apply: () => ({ text: 'foo', compressed: true }), +}; + +describe('ToolResultCompressorService', () => { + it('returns undefined when disabled', () => { + const svc = makeService({ enabled: false }); + svc.registerFilter(replaceWithFooFilter); + const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]); + expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined(); + }); + + it('returns undefined when no filters registered', () => { + const svc = makeService({ enabled: true }); + const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]); + expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined(); + }); + + it('returns undefined when no filters match', () => { + const svc = makeService({ enabled: true }); + svc.registerFilter({ + id: 'no-match', + toolNames: [TOOL], + matches: () => false, + apply: () => ({ text: 'foo', compressed: true }), + }); + const result = new LanguageModelToolResult([new LanguageModelTextPart(longText('hello'))]); + expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined(); + }); + + it('disables a throwing filter for the rest of the pass and warns once', () => { + const warnings: string[] = []; + const svc = makeService({ enabled: true, warnings }); + let calls = 0; + svc.registerFilter({ + id: 'thrower', + toolNames: [TOOL], + matches: () => true, + apply: () => { calls++; throw new Error('boom'); }, + }); + svc.registerFilter(replaceWithFooFilter); + const result = new LanguageModelToolResult([ + new LanguageModelTextPart(longText('a')), + new LanguageModelTextPart(longText('b')), + new LanguageModelTextPart(longText('c')), + ]); + const out = svc.maybeCompress(TOOL, {}, result)!; + expect(out).toBeDefined(); + // Throwing filter is invoked exactly once on the first text part, then disabled. + expect(calls).toBe(1); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('thrower'); + // The other filter still rewrites every text part. + for (const part of out.content) { + expect(part).toBeInstanceOf(LanguageModelTextPart); + expect((part as LanguageModelTextPart).value).toBe('foo'); + } + }); + + it('preserves non-text parts unchanged', () => { + const svc = makeService({ enabled: true }); + svc.registerFilter(replaceWithFooFilter); + const dataPart = new LanguageModelDataPart(new Uint8Array([1, 2, 3]), 'application/octet-stream'); + const textPart = new LanguageModelTextPart(longText('hello')); + const result = new LanguageModelToolResult([dataPart, textPart]); + const out = svc.maybeCompress(TOOL, {}, result)!; + expect(out.content[0]).toBe(dataPart); + expect(out.content[1]).toBeInstanceOf(LanguageModelTextPart); + expect((out.content[1] as LanguageModelTextPart).value).toBe('foo'); + }); + + it('preserves LanguageModelTextPart2 audience metadata when rewriting', () => { + const svc = makeService({ enabled: true }); + svc.registerFilter(replaceWithFooFilter); + const audience = [LanguageModelPartAudience.Assistant, LanguageModelPartAudience.User]; + const part = new LanguageModelTextPart2(longText('hello'), audience); + const result = new LanguageModelToolResult([part]); + const out = svc.maybeCompress(TOOL, {}, result)!; + expect(out.content[0]).toBeInstanceOf(LanguageModelTextPart2); + const rewritten = out.content[0] as LanguageModelTextPart2; + expect(rewritten.value).toBe('foo'); + expect(rewritten.audience).toEqual(audience); + }); + + it('skips text parts shorter than the minimum compressible length', () => { + const svc = makeService({ enabled: true }); + svc.registerFilter(replaceWithFooFilter); + const result = new LanguageModelToolResult([new LanguageModelTextPart('tiny')]); + // Nothing was compressed because the part was below the threshold. + expect(svc.maybeCompress(TOOL, {}, result)).toBeUndefined(); + }); +}); diff --git a/src/extension/tools/common/toolResultCompressor.ts b/src/extension/tools/common/toolResultCompressor.ts new file mode 100644 index 0000000000..483f7dd81a --- /dev/null +++ b/src/extension/tools/common/toolResultCompressor.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; +import { createServiceIdentifier } from '../../../util/common/services'; +import { LanguageModelTextPart, LanguageModelTextPart2 } from '../../../vscodeTypes'; + +export const IToolResultCompressor = createServiceIdentifier('IToolResultCompressor'); + +/** + * Result of running a {@link IToolResultFilter}. + * + * `text` is the new text to substitute back into the corresponding text part. + * `compressed` is `true` if any compression actually happened — used purely + * for telemetry / accounting. + */ +export interface IToolResultFilterOutput { + readonly text: string; + readonly compressed: boolean; +} + +/** + * A pure function that compresses a single text part of a tool result. + * + * Implementations MUST never make output worse than the input. If a filter + * cannot improve a piece of text, it should return the original `text` and + * `compressed: false`. + */ +export interface IToolResultFilter { + readonly id: string; + /** Tool names this filter applies to. */ + readonly toolNames: readonly string[]; + /** + * Decide whether this filter wants to handle the result. May inspect tool + * input (e.g. for `run_in_terminal`, the command being run). + */ + matches(toolName: string, input: unknown): boolean; + apply(text: string, input: unknown): IToolResultFilterOutput; +} + +export interface IToolResultCompressor { + readonly _serviceBrand: undefined; + registerFilter(filter: IToolResultFilter): void; + /** + * Returns a possibly-compressed copy of `result`, or `undefined` if no + * compression was applied (caller should pass through the original). + */ + maybeCompress(toolName: string, input: unknown, result: vscode.LanguageModelToolResult | vscode.LanguageModelToolResult2): vscode.LanguageModelToolResult | undefined; +} + +/** + * Outputs at or below this many characters (UTF-16 code units, i.e. + * `string.length`) are not worth compressing. Mirrors ztk's 80-character + * minimum. + */ +const MIN_COMPRESSIBLE_LENGTH = 80; + +export class ToolResultCompressorService implements IToolResultCompressor { + declare readonly _serviceBrand: undefined; + + private readonly _filters = new Map(); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + ) { } + + registerFilter(filter: IToolResultFilter): void { + for (const name of filter.toolNames) { + let bucket = this._filters.get(name); + if (!bucket) { + bucket = []; + this._filters.set(name, bucket); + } + bucket.push(filter); + } + } + + maybeCompress(toolName: string, input: unknown, result: vscode.LanguageModelToolResult | vscode.LanguageModelToolResult2): vscode.LanguageModelToolResult | undefined { + if (!this._configurationService.getConfig(ConfigKey.ToolResultCompressionEnabled)) { + return undefined; + } + + const filters = this._filters.get(toolName); + if (!filters || filters.length === 0) { + return undefined; + } + + const matchingFilters = filters.filter(f => f.matches(toolName, input)); + if (matchingFilters.length === 0) { + return undefined; + } + + // Mutable copy: filters that throw get spliced out so we don't repeatedly + // invoke a broken filter on every subsequent text part in this pass. + const activeFilters = matchingFilters.slice(); + const disabledFilterIds = new Set(); + + let totalBefore = 0; + let totalAfter = 0; + let anyCompressed = false; + const usedFilterIds = new Set(); + + const newContent = result.content.map(part => { + if (!(part instanceof LanguageModelTextPart)) { + return part; + } + const original = part.value; + if (original.length < MIN_COMPRESSIBLE_LENGTH) { + return part; + } + + let current = original; + for (let i = 0; i < activeFilters.length; /* manual increment */) { + const filter = activeFilters[i]; + try { + const out = filter.apply(current, input); + if (out.compressed && out.text.length < current.length) { + current = out.text; + usedFilterIds.add(filter.id); + } + i++; + } catch (err) { + // "Never make it worse." Disable the filter for the rest of this + // compression pass so it can't repeatedly throw on later text parts, + // and warn at most once per filter. + activeFilters.splice(i, 1); + if (!disabledFilterIds.has(filter.id)) { + disabledFilterIds.add(filter.id); + this._logService.warn(`[ToolResultCompressor] filter ${filter.id} threw on tool ${toolName}; disabled for this pass: ${err}`); + } + } + } + + totalBefore += original.length; + totalAfter += current.length; + if (current !== original) { + anyCompressed = true; + // Preserve LanguageModelTextPart2 audience metadata if present. + if (part instanceof LanguageModelTextPart2) { + return new LanguageModelTextPart2(current, part.audience); + } + return new LanguageModelTextPart(current); + } + return part; + }); + + if (!anyCompressed) { + return undefined; + } + + this._sendTelemetry(toolName, [...usedFilterIds], totalBefore, totalAfter); + + // Preserve `toolResultMessage`/`toolResultDetails` if present (ExtendedLanguageModelToolResult shape). + const compressed: vscode.LanguageModelToolResult & { toolResultMessage?: unknown; toolResultDetails?: unknown } = + Object.assign(Object.create(Object.getPrototypeOf(result)), result, { content: newContent }); + return compressed as vscode.LanguageModelToolResult; + } + + private _sendTelemetry(toolName: string, filterIds: string[], beforeChars: number, afterChars: number) { + /* __GDPR__ + "toolResultCompressed" : { + "owner": "meganrogge", + "comment": "Reports tool output compression savings.", + "toolName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The tool whose output was compressed." }, + "filters": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Comma-separated filter ids that fired." }, + "beforeChars": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part length in UTF-16 code units before compression." }, + "afterChars": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part length in UTF-16 code units after compression." } + } + */ + this._telemetryService.sendMSFTTelemetryEvent( + 'toolResultCompressed', + { toolName, filters: filterIds.join(',') }, + { beforeChars, afterChars }, + ); + } +} diff --git a/src/extension/tools/node/compressors/terminalOutputCompressor.ts b/src/extension/tools/node/compressors/terminalOutputCompressor.ts new file mode 100644 index 0000000000..4df1afb314 --- /dev/null +++ b/src/extension/tools/node/compressors/terminalOutputCompressor.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ToolName } from '../../common/toolNames'; +import { IToolResultCompressor, IToolResultFilter, IToolResultFilterOutput } from '../../common/toolResultCompressor'; + +/** + * Input shape used by the core `run_in_terminal` tool. We only depend on the + * `command` field; everything else is ignored. + */ +interface ITerminalInput { + command?: string; +} + +/** + * Returns the "head" of a shell command — the first executable word, after + * skipping common env-var assignments like `FOO=bar baz`. `sub` is the first + * non-long-flag token after the head, so `git --no-pager diff` yields + * `{ head: 'git', sub: 'diff' }`. Returns `undefined` when the command can't + * be parsed. + */ +export function parseCommandHead(command: string | undefined): { head: string; sub: string | undefined } | undefined { + if (!command) { + return undefined; + } + // Take only the first pipeline segment so `git diff | cat` still routes to git. + const firstSegment = command.split(/[|;&]/)[0].trim(); + if (!firstSegment) { + return undefined; + } + const tokens = firstSegment.split(/\s+/).filter(t => !/^[A-Z_][A-Z0-9_]*=/.test(t)); + const head = tokens[0]; + if (!head) { + return undefined; + } + // Skip leading long flags like `--no-pager` so `git --no-pager diff` parses + // as `{ head: 'git', sub: 'diff' }`. Short flags (`-la`) stay as the sub + // because for tools like `ls` they're the entire intent. + let sub: string | undefined; + for (let i = 1; i < tokens.length; i++) { + if (tokens[i].startsWith('--')) { + continue; + } + sub = tokens[i]; + break; + } + return { head, sub }; +} + +function isTerminalInput(input: unknown): input is ITerminalInput { + return typeof input === 'object' && input !== null && 'command' in input; +} + +/** + * Compresses `git diff` / `git show` output by reducing context lines to a + * tighter window and dropping the huge no-op chunks that diffs of generated + * files (lockfiles, snapshots) produce. + */ +export const gitDiffFilter: IToolResultFilter = { + id: 'terminal.git-diff', + toolNames: [ToolName.CoreRunInTerminal], + matches(_toolName, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommandHead(input.command); + return parsed?.head === 'git' && (parsed.sub === 'diff' || parsed.sub === 'show'); + }, + apply(text): IToolResultFilterOutput { + const lines = text.split('\n'); + const out: string[] = []; + // Number of context lines to keep at the start of each unchanged run before + // collapsing the rest into a single "... N omitted ..." marker. + const KEEP_CONTEXT = 1; + let contextRun = 0; + let inBinaryOrLock = false; + + const flushContextRun = () => { + const omitted = contextRun - KEEP_CONTEXT; + if (omitted > 0) { + out.push(`... ${omitted} unchanged context line${omitted === 1 ? '' : 's'} omitted ...`); + } + contextRun = 0; + }; + + for (const line of lines) { + if (line.startsWith('diff --git')) { + flushContextRun(); + inBinaryOrLock = /package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.lockb?$|\.snap$/.test(line); + if (inBinaryOrLock) { + out.push(line); + out.push('... lockfile/snapshot diff omitted ...'); + continue; + } + out.push(line); + continue; + } + if (inBinaryOrLock) { + continue; + } + // Drop noisy headers we don't need. + if (line.startsWith('index ') || line.startsWith('similarity index ') || + line.startsWith('dissimilarity index ') || line.startsWith('rename from ') || + line.startsWith('rename to ')) { + continue; + } + // Keep file-mode markers, hunk markers, +/- lines verbatim. + if (line.startsWith('+++ ') || line.startsWith('--- ') || line.startsWith('@@') || + line.startsWith('+') || line.startsWith('-') || line.startsWith('Binary files ')) { + flushContextRun(); + out.push(line); + continue; + } + // Unchanged context line: keep the first KEEP_CONTEXT lines of each run, + // then count the rest so the next non-context line can flush a single + // summary marker. + contextRun++; + if (contextRun <= KEEP_CONTEXT) { + out.push(line); + } + } + flushContextRun(); + + const result = out.join('\n'); + return { + text: result, + compressed: result.length < text.length, + }; + }, +}; + +/** + * Compresses `ls -l` / `ls -la` output by dropping permission/owner/size + * columns and keeping only the entry name. Plain `ls` is already terse and + * passes through. + */ +export const lsFilter: IToolResultFilter = { + id: 'terminal.ls', + toolNames: [ToolName.CoreRunInTerminal], + matches(_toolName, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommandHead(input.command); + if (parsed?.head !== 'ls') { + return false; + } + // Only worth running on long-form listings. + return /\s-\w*l/.test(input.command ?? ''); + }, + apply(text): IToolResultFilterOutput { + const lines = text.split('\n'); + const out: string[] = []; + let dropped = 0; + // `ls -l` line: perms links owner group size date1 date2 date3 name + const longRe = /^[-dlcbpsDLCBPS][rwx\-tTsS@+.]{9,}\s+\d+\s+\S+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.+)$/; + for (const line of lines) { + if (!line.trim()) { + continue; + } + if (line.startsWith('total ')) { + dropped++; + continue; + } + const m = longRe.exec(line); + if (m) { + const isDir = line.startsWith('d'); + out.push(isDir ? m[1] + '/' : m[1]); + } else { + out.push(line); + } + } + void dropped; + const result = out.join('\n'); + return { + text: result, + compressed: result.length < text.length, + }; + }, +}; + +/** + * Compresses `npm install` / `yarn` / `pnpm install` output by stripping + * progress lines and audit summary noise, keeping the package summary plus + * any error/warning lines. + */ +export const npmInstallFilter: IToolResultFilter = { + id: 'terminal.npm-install', + toolNames: [ToolName.CoreRunInTerminal], + matches(_toolName, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommandHead(input.command); + if (!parsed) { + return false; + } + if (parsed.head === 'npm' && (parsed.sub === 'install' || parsed.sub === 'i' || parsed.sub === 'ci')) { + return true; + } + if ((parsed.head === 'yarn' || parsed.head === 'pnpm') && parsed.sub !== 'test') { + return /\binstall\b|\badd\b/.test(input.command ?? '') || parsed.sub === undefined; + } + return false; + }, + apply(text): IToolResultFilterOutput { + const lines = text.split('\n'); + const dropPatterns: RegExp[] = [ + /^npm warn deprecated /i, + /^\s*\[#+>?\s*\] /, // progress bars + /^npm http /i, + /^npm timing /i, + /^npm sill /i, + /^npm verb /i, + /^\s*\d+ packages? are looking for funding/i, + /run `npm fund`/i, + /^Run `npm audit/i, + ]; + const out: string[] = []; + for (const line of lines) { + if (dropPatterns.some(re => re.test(line))) { + continue; + } + out.push(line); + } + const result = out.join('\n'); + return { + text: result, + compressed: result.length < text.length, + }; + }, +}; + +export function registerTerminalCompressors(compressor: IToolResultCompressor): void { + compressor.registerFilter(gitDiffFilter); + compressor.registerFilter(lsFilter); + compressor.registerFilter(npmInstallFilter); +} diff --git a/src/extension/tools/node/test/terminalOutputCompressor.spec.ts b/src/extension/tools/node/test/terminalOutputCompressor.spec.ts new file mode 100644 index 0000000000..88daaa8a42 --- /dev/null +++ b/src/extension/tools/node/test/terminalOutputCompressor.spec.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { gitDiffFilter, lsFilter, npmInstallFilter, parseCommandHead } from '../compressors/terminalOutputCompressor'; + +describe('parseCommandHead', () => { + it('returns undefined for empty input', () => { + expect(parseCommandHead(undefined)).toBeUndefined(); + expect(parseCommandHead('')).toBeUndefined(); + expect(parseCommandHead(' ')).toBeUndefined(); + }); + it('parses simple commands', () => { + expect(parseCommandHead('git diff HEAD~5')).toEqual({ head: 'git', sub: 'diff' }); + expect(parseCommandHead('ls -la')).toEqual({ head: 'ls', sub: '-la' }); + }); + it('skips env-var prefixes', () => { + expect(parseCommandHead('CI=1 NODE_ENV=test npm install')).toEqual({ head: 'npm', sub: 'install' }); + }); + it('uses only first pipeline segment', () => { + expect(parseCommandHead('git diff | cat')).toEqual({ head: 'git', sub: 'diff' }); + }); + it('skips leading long flags before the subcommand', () => { + expect(parseCommandHead('git --no-pager diff src/foo.ts')).toEqual({ head: 'git', sub: 'diff' }); + }); + it('skips short flag plus value before the subcommand', () => { + expect(parseCommandHead('git -C /tmp/repo diff')).toEqual({ head: 'git', sub: '-C' }); + }); +}); + +describe('gitDiffFilter', () => { + const input = { command: 'git diff HEAD~1' }; + + it('matches git diff', () => { + expect(gitDiffFilter.matches('run_in_terminal', input)).toBe(true); + }); + it('matches git --no-pager diff', () => { + expect(gitDiffFilter.matches('run_in_terminal', { command: 'git --no-pager diff src/foo.ts' })).toBe(true); + }); + it('does not match git status', () => { + expect(gitDiffFilter.matches('run_in_terminal', { command: 'git status' })).toBe(false); + }); + it('preserves +/- and hunk headers verbatim', () => { + const text = [ + 'diff --git a/foo.ts b/foo.ts', + 'index abc..def 100644', + '--- a/foo.ts', + '+++ b/foo.ts', + '@@ -1,3 +1,3 @@', + ' unchanged', + '-old', + '+new', + ' unchanged', + ].join('\n'); + const out = gitDiffFilter.apply(text, input); + expect(out.text).toContain('-old'); + expect(out.text).toContain('+new'); + expect(out.text).toContain('@@ -1,3 +1,3 @@'); + expect(out.text).not.toContain('index abc..def'); + }); + it('collapses long unchanged-context runs into a single marker', () => { + const ctxLines = Array.from({ length: 20 }, (_, i) => ` this is context line number ${i}`); + const text = [ + 'diff --git a/foo.ts b/foo.ts', + '--- a/foo.ts', + '+++ b/foo.ts', + '@@ -1,22 +1,22 @@', + ...ctxLines, + '-old', + '+new', + ].join('\n'); + const out = gitDiffFilter.apply(text, input); + // First context line is kept verbatim, the next 19 are collapsed. + expect(out.text).toContain(' this is context line number 0'); + expect(out.text).not.toContain(' this is context line number 5'); + expect(out.text).not.toContain(' this is context line number 19'); + expect(out.text).toContain('19 unchanged context lines omitted'); + expect(out.text).toContain('-old'); + expect(out.text).toContain('+new'); + expect(out.compressed).toBe(true); + }); + it('omits lockfile diffs', () => { + const text = [ + 'diff --git a/package-lock.json b/package-lock.json', + 'index 1..2 100644', + '--- a/package-lock.json', + '+++ b/package-lock.json', + '@@ -1,3 +1,3 @@', + '-old', + '+new', + ].join('\n'); + const out = gitDiffFilter.apply(text, input); + expect(out.text).toContain('lockfile/snapshot diff omitted'); + expect(out.text).not.toContain('-old'); + expect(out.compressed).toBe(true); + }); +}); + +describe('lsFilter', () => { + it('matches only when -l flag present', () => { + expect(lsFilter.matches('run_in_terminal', { command: 'ls' })).toBe(false); + expect(lsFilter.matches('run_in_terminal', { command: 'ls -la' })).toBe(true); + expect(lsFilter.matches('run_in_terminal', { command: 'ls -al src/' })).toBe(true); + }); + it('strips long-form columns and keeps file names', () => { + const text = [ + 'total 24', + '-rw-r--r-- 1 user staff 123 Jan 01 12:34 README.md', + 'drwxr-xr-x 5 user staff 160 Jan 01 12:34 src', + ].join('\n'); + const out = lsFilter.apply(text, { command: 'ls -la' }); + expect(out.text).toContain('README.md'); + expect(out.text).toContain('src/'); + expect(out.text).not.toContain('user staff'); + expect(out.text).not.toContain('total 24'); + expect(out.compressed).toBe(true); + }); +}); + +describe('npmInstallFilter', () => { + it('matches npm install', () => { + expect(npmInstallFilter.matches('run_in_terminal', { command: 'npm install' })).toBe(true); + expect(npmInstallFilter.matches('run_in_terminal', { command: 'npm ci' })).toBe(true); + expect(npmInstallFilter.matches('run_in_terminal', { command: 'npm test' })).toBe(false); + }); + it('drops audit and funding noise', () => { + const text = [ + 'added 250 packages in 12s', + 'npm warn deprecated foo@1.0.0: please update', + '42 packages are looking for funding', + ' run `npm fund` for details', + '', + '3 vulnerabilities (1 low, 2 moderate)', + 'Run `npm audit` for details.', + ].join('\n'); + const out = npmInstallFilter.apply(text, { command: 'npm install' }); + expect(out.text).toContain('added 250 packages'); + expect(out.text).not.toContain('deprecated foo'); + expect(out.text).not.toContain('looking for funding'); + expect(out.text).not.toContain('npm audit'); + expect(out.compressed).toBe(true); + }); +}); diff --git a/src/extension/tools/vscode-node/toolsService.ts b/src/extension/tools/vscode-node/toolsService.ts index de616a5631..958665a700 100644 --- a/src/extension/tools/vscode-node/toolsService.ts +++ b/src/extension/tools/vscode-node/toolsService.ts @@ -16,8 +16,10 @@ import { isDisposable } from '../../../util/vs/base/common/lifecycle'; import { autorunIterableDelta } from '../../../util/vs/base/common/observableInternal'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { getContributedToolName, getToolName, mapContributedToolNamesInSchema, mapContributedToolNamesInString, ToolName } from '../common/toolNames'; +import { IToolResultCompressor } from '../common/toolResultCompressor'; import { ICopilotTool, ICopilotToolExtension, modelSpecificToolApplies, ToolRegistry } from '../common/toolsRegistry'; import { BaseToolsService } from '../common/toolsService'; +import { registerTerminalCompressors } from '../node/compressors/terminalOutputCompressor'; export class ToolsService extends BaseToolsService { declare _serviceBrand: undefined; @@ -86,10 +88,12 @@ export class ToolsService extends BaseToolsService { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService logService: ILogService, @IOTelService private readonly _otelService: IOTelService, + @IToolResultCompressor private readonly _toolResultCompressor: IToolResultCompressor, ) { super(logService); this._copilotTools = new Lazy(() => new Map(ToolRegistry.getTools().map(t => [t.toolName, _instantiationService.createInstance(t)] as const))); this._toolExtensions = new Lazy(() => new Map(ToolRegistry.getToolExtensions().map(t => [t.toolName, _instantiationService.createInstance(t)] as const))); + registerTerminalCompressors(this._toolResultCompressor); } private getModelSpecificTools() { @@ -174,6 +178,12 @@ export class ToolsService extends BaseToolsService { return vscode.lm.invokeTool(getContributedToolName(name), options, token).then( result => { span.setStatus(SpanStatusCode.OK); + // Apply post-processing compression (e.g. for run_in_terminal output) before + // the result reaches the model. Returns undefined when no compression applied. + const compressed = this._toolResultCompressor.maybeCompress(String(name), options.input, result); + if (compressed) { + result = compressed; + } // Always capture tool result for the debug panel try { const parts: string[] = []; diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 07733065b1..1e4163779e 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -1004,6 +1004,8 @@ export namespace ConfigKey { export const CopilotMemoryEnabled = defineSetting('chat.copilotMemory.enabled', ConfigType.ExperimentBased, false); export const MemoryToolEnabled = defineSetting('chat.tools.memory.enabled', ConfigType.ExperimentBased, true); export const ViewImageToolEnabled = defineSetting('chat.tools.viewImage.enabled', ConfigType.ExperimentBased, true); + /** Enable post-processing compression of tool results (e.g. terminal output). */ + export const ToolResultCompressionEnabled = defineSetting('chat.tools.compressOutput.enabled', ConfigType.Simple, false); } export function getAllConfigKeys(): string[] {