Skip to content

Commit 5a4e6db

Browse files
Megan RoggeMegan Rogge
authored andcommitted
Add tool result compression layer (terminal output filters)
1 parent 9e668cb commit 5a4e6db

8 files changed

Lines changed: 527 additions & 0 deletions

File tree

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,6 +3231,14 @@
32313231
"onExp"
32323232
]
32333233
},
3234+
"github.copilot.chat.tools.compressOutput.enabled": {
3235+
"type": "boolean",
3236+
"default": false,
3237+
"markdownDescription": "%github.copilot.config.tools.compressOutput.enabled%",
3238+
"tags": [
3239+
"preview"
3240+
]
3241+
},
32343242
"github.copilot.chat.backgroundCompaction": {
32353243
"type": "boolean",
32363244
"default": false,

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"copilot.tools.viewImage.name": "View Image",
298298
"copilot.tools.viewImage.userDescription": "View the contents of an image file",
299299
"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.",
300+
"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.",
300301
"copilot.tools.listDirectory.name": "List Dir",
301302
"copilot.tools.listDirectory.userDescription": "List the contents of a directory",
302303
"copilot.tools.getTaskOutput.name": "Get Task Output",

src/extension/extension/vscode-node/services.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ import { AgentMemoryService, IAgentMemoryService } from '../../tools/common/agen
139139
import { IMemoryCleanupService, MemoryCleanupService } from '../../tools/common/memoryCleanupService';
140140
import { IToolDeferralService } from '../../../platform/networking/common/toolDeferralService';
141141
import { ToolDeferralService } from '../../tools/common/toolDeferralService';
142+
import { IToolResultCompressor, ToolResultCompressorService } from '../../tools/common/toolResultCompressor';
142143
import { IToolsService } from '../../tools/common/toolsService';
143144
import { ToolsService } from '../../tools/vscode-node/toolsService';
144145
import { LanguageContextServiceImpl } from '../../typescriptContext/vscode-node/languageContextService';
@@ -166,6 +167,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
166167
builder.define(ITokenizerProvider, new SyncDescriptor(TokenizerProvider, [true]));
167168
builder.define(IToolsService, new SyncDescriptor(ToolsService));
168169
builder.define(IToolDeferralService, new ToolDeferralService());
170+
builder.define(IToolResultCompressor, new SyncDescriptor(ToolResultCompressorService));
169171
builder.define(IAgentMemoryService, new SyncDescriptor(AgentMemoryService));
170172
builder.define(IMemoryCleanupService, new SyncDescriptor(MemoryCleanupService));
171173
builder.define(IChatDiskSessionResources, new SyncDescriptor(ChatDiskSessionResources));
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 type * as vscode from 'vscode';
7+
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
8+
import { ILogService } from '../../../platform/log/common/logService';
9+
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
10+
import { createServiceIdentifier } from '../../../util/common/services';
11+
import { LanguageModelTextPart } from '../../../vscodeTypes';
12+
13+
export const IToolResultCompressor = createServiceIdentifier<IToolResultCompressor>('IToolResultCompressor');
14+
15+
/**
16+
* Result of running a {@link IToolResultFilter}.
17+
*
18+
* `text` is the new text to substitute back into the corresponding text part.
19+
* `compressed` is `true` if any compression actually happened — used purely
20+
* for telemetry / accounting.
21+
*/
22+
export interface IToolResultFilterOutput {
23+
readonly text: string;
24+
readonly compressed: boolean;
25+
}
26+
27+
/**
28+
* A pure function that compresses a single text part of a tool result.
29+
*
30+
* Implementations MUST never make output worse than the input. If a filter
31+
* cannot improve a piece of text, it should return the original `text` and
32+
* `compressed: false`.
33+
*/
34+
export interface IToolResultFilter {
35+
readonly id: string;
36+
/** Tool names this filter applies to. */
37+
readonly toolNames: readonly string[];
38+
/**
39+
* Decide whether this filter wants to handle the result. May inspect tool
40+
* input (e.g. for `run_in_terminal`, the command being run).
41+
*/
42+
matches(toolName: string, input: unknown): boolean;
43+
apply(text: string, input: unknown): IToolResultFilterOutput;
44+
}
45+
46+
export interface IToolResultCompressor {
47+
readonly _serviceBrand: undefined;
48+
registerFilter(filter: IToolResultFilter): void;
49+
/**
50+
* Returns a possibly-compressed copy of `result`, or `undefined` if no
51+
* compression was applied (caller should pass through the original).
52+
*/
53+
maybeCompress(toolName: string, input: unknown, result: vscode.LanguageModelToolResult | vscode.LanguageModelToolResult2): vscode.LanguageModelToolResult | undefined;
54+
}
55+
56+
/**
57+
* Outputs at or below this size are not worth compressing.
58+
* Mirrors ztk's 80-byte minimum.
59+
*/
60+
const MIN_COMPRESSIBLE_LENGTH = 80;
61+
62+
export class ToolResultCompressorService implements IToolResultCompressor {
63+
declare readonly _serviceBrand: undefined;
64+
65+
private readonly _filters = new Map<string, IToolResultFilter[]>();
66+
67+
constructor(
68+
@IConfigurationService private readonly _configurationService: IConfigurationService,
69+
@ITelemetryService private readonly _telemetryService: ITelemetryService,
70+
@ILogService private readonly _logService: ILogService,
71+
) { }
72+
73+
registerFilter(filter: IToolResultFilter): void {
74+
for (const name of filter.toolNames) {
75+
let bucket = this._filters.get(name);
76+
if (!bucket) {
77+
bucket = [];
78+
this._filters.set(name, bucket);
79+
}
80+
bucket.push(filter);
81+
}
82+
}
83+
84+
maybeCompress(toolName: string, input: unknown, result: vscode.LanguageModelToolResult | vscode.LanguageModelToolResult2): vscode.LanguageModelToolResult | undefined {
85+
if (!this._configurationService.getConfig(ConfigKey.ToolResultCompressionEnabled)) {
86+
return undefined;
87+
}
88+
89+
const filters = this._filters.get(toolName);
90+
if (!filters || filters.length === 0) {
91+
return undefined;
92+
}
93+
94+
const matchingFilters = filters.filter(f => f.matches(toolName, input));
95+
if (matchingFilters.length === 0) {
96+
return undefined;
97+
}
98+
99+
let totalBefore = 0;
100+
let totalAfter = 0;
101+
let anyCompressed = false;
102+
const usedFilterIds = new Set<string>();
103+
104+
const newContent = result.content.map(part => {
105+
if (!(part instanceof LanguageModelTextPart)) {
106+
return part;
107+
}
108+
const original = part.value;
109+
if (original.length < MIN_COMPRESSIBLE_LENGTH) {
110+
return part;
111+
}
112+
113+
let current = original;
114+
for (const filter of matchingFilters) {
115+
try {
116+
const out = filter.apply(current, input);
117+
if (out.compressed && out.text.length < current.length) {
118+
current = out.text;
119+
usedFilterIds.add(filter.id);
120+
}
121+
} catch (err) {
122+
// "Never make it worse." Drop the filter on error and keep going.
123+
this._logService.warn(`[ToolResultCompressor] filter ${filter.id} threw on tool ${toolName}: ${err}`);
124+
}
125+
}
126+
127+
totalBefore += original.length;
128+
totalAfter += current.length;
129+
if (current !== original) {
130+
anyCompressed = true;
131+
return new LanguageModelTextPart(current);
132+
}
133+
return part;
134+
});
135+
136+
if (!anyCompressed) {
137+
return undefined;
138+
}
139+
140+
this._sendTelemetry(toolName, [...usedFilterIds], totalBefore, totalAfter);
141+
142+
// Preserve `toolResultMessage`/`toolResultDetails` if present (ExtendedLanguageModelToolResult shape).
143+
const compressed: vscode.LanguageModelToolResult & { toolResultMessage?: unknown; toolResultDetails?: unknown } =
144+
Object.assign(Object.create(Object.getPrototypeOf(result)), result, { content: newContent });
145+
return compressed as vscode.LanguageModelToolResult;
146+
}
147+
148+
private _sendTelemetry(toolName: string, filterIds: string[], beforeBytes: number, afterBytes: number) {
149+
/* __GDPR__
150+
"toolResultCompressed" : {
151+
"owner": "meganrogge",
152+
"comment": "Reports tool output compression savings.",
153+
"toolName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The tool whose output was compressed." },
154+
"filters": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Comma-separated filter ids that fired." },
155+
"beforeBytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part bytes before compression." },
156+
"afterBytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Total text part bytes after compression." }
157+
}
158+
*/
159+
this._telemetryService.sendMSFTTelemetryEvent(
160+
'toolResultCompressed',
161+
{ toolName, filters: filterIds.join(',') },
162+
{ beforeBytes, afterBytes },
163+
);
164+
}
165+
}

0 commit comments

Comments
 (0)