Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/extension/extension/vscode-node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
136 changes: 136 additions & 0 deletions src/extension/tools/common/test/toolResultCompressor.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
183 changes: 183 additions & 0 deletions src/extension/tools/common/toolResultCompressor.ts
Original file line number Diff line number Diff line change
@@ -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>('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;

Comment thread
meganrogge marked this conversation as resolved.
export class ToolResultCompressorService implements IToolResultCompressor {
declare readonly _serviceBrand: undefined;

private readonly _filters = new Map<string, IToolResultFilter[]>();

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<string>();

let totalBefore = 0;
let totalAfter = 0;
let anyCompressed = false;
const usedFilterIds = new Set<string>();

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}`);
}
}
Comment thread
meganrogge marked this conversation as resolved.
}

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;
Comment thread
meganrogge marked this conversation as resolved.
});

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;
}
Comment thread
meganrogge marked this conversation as resolved.

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 },
);
}
}
Loading
Loading