feat(agent): added adaptive completion debouncing. (#389)

* feat(agent): added adaptive completion debouncing.

* chore: bump tabby-agent version to v0.1.0-dev.
release-v0.1
Zhiming Ma 2023-09-01 13:30:53 +08:00 committed by GitHub
parent 5dff349801
commit 1c1cf44639
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 165 additions and 47 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "tabby-agent", "name": "tabby-agent",
"version": "0.0.1", "version": "0.1.0-dev",
"description": "Generic client agent for Tabby AI coding assistant IDE extensions.", "description": "Generic client agent for Tabby AI coding assistant IDE extensions.",
"repository": "https://github.com/TabbyML/tabby", "repository": "https://github.com/TabbyML/tabby",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -20,8 +20,6 @@ export type CompletionRequest = {
text: string; text: string;
position: number; position: number;
manually?: boolean; manually?: boolean;
maxPrefixLines?: number;
maxSuffixLines?: number;
}; };
export type CompletionResponse = ApiCompletionResponse; export type CompletionResponse = ApiCompletionResponse;
@ -116,11 +114,14 @@ export interface AgentFunction {
waitForAuthToken(code: string): CancelablePromise<any>; waitForAuthToken(code: string): CancelablePromise<any>;
/** /**
* Provide completions for the given request. This method is debounced, calling it before the previous
* call is resolved will cancel the previous call. The debouncing interval is automatically calculated
* or can be set in the config.
* @param request * @param request
* @returns * @returns
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>; provideCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>;
/** /**
* @param event * @param event

View File

@ -7,12 +7,18 @@ export type AgentConfig = {
requestTimeout: number; requestTimeout: number;
}; };
completion: { completion: {
prompt: {
maxPrefixLines: number;
maxSuffixLines: number;
};
debounce: {
mode: "adaptive" | "fixed";
interval: number;
};
timeout: { timeout: {
auto: number; auto: number;
manually: number; manually: number;
}; };
maxPrefixLines: number;
maxSuffixLines: number;
}; };
logs: { logs: {
level: "debug" | "error" | "silent"; level: "debug" | "error" | "silent";
@ -39,12 +45,18 @@ export const defaultAgentConfig: AgentConfig = {
requestTimeout: 30000, // 30s requestTimeout: 30000, // 30s
}, },
completion: { completion: {
prompt: {
maxPrefixLines: 20,
maxSuffixLines: 20,
},
debounce: {
mode: "adaptive",
interval: 250, // ms
},
timeout: { timeout: {
auto: 5000, // 5s auto: 5000, // 5s
manually: 30000, // 30s manually: 30000, // 30s
}, },
maxPrefixLines: 20,
maxSuffixLines: 20,
}, },
logs: { logs: {
level: "silent", level: "silent",

View File

@ -0,0 +1,114 @@
import { CancelablePromise } from "./generated";
import { CompletionRequest } from "./Agent";
import { AgentConfig } from "./AgentConfig";
import { rootLogger } from "./logger";
import { splitLines } from "./utils";
function clamp(min: number, max: number, value: number): number {
return Math.max(min, Math.min(max, value));
}
export class CompletionDebounce {
private readonly logger = rootLogger.child({ component: "CompletionDebounce" });
private ongoing: CancelablePromise<any> | null = null;
private lastCalledTimeStamp = 0;
private baseInterval = 200; // ms
private calledIntervalHistory: number[] = [];
private options = {
baseIntervalSlideWindowAvg: {
minSize: 20,
maxSize: 100,
min: 100,
max: 400,
},
adaptiveRate: {
min: 1.5,
max: 3.0,
},
contextScoreWeights: {
triggerCharacter: 0.5,
noSuffixInCurrentLine: 0.4,
noSuffix: 0.1,
},
requestDelay: {
min: 100, // ms
max: 1000,
},
};
debounce(
request: CompletionRequest,
config: AgentConfig["completion"]["debounce"],
responseTime: number,
): CancelablePromise<any> {
if (request.manually) {
return this.renewPromise(0);
}
if (config.mode === "fixed") {
return this.renewPromise(config.interval);
}
const now = Date.now();
this.updateBaseInterval(now - this.lastCalledTimeStamp);
this.lastCalledTimeStamp = now;
const contextScore = this.calcContextScore(request);
const adaptiveRate =
this.options.adaptiveRate.max - (this.options.adaptiveRate.max - this.options.adaptiveRate.min) * contextScore;
const expectedLatency = adaptiveRate * this.baseInterval;
const delay = clamp(this.options.requestDelay.min, this.options.requestDelay.max, expectedLatency - responseTime);
return this.renewPromise(delay);
}
private renewPromise(delay: number): CancelablePromise<any> {
if (this.ongoing) {
this.ongoing.cancel();
}
this.ongoing = new CancelablePromise<any>((resolve, reject, onCancel) => {
const timer = setTimeout(
() => {
resolve(true);
},
Math.min(delay, 0x7fffffff),
);
onCancel(() => {
clearTimeout(timer);
});
});
return this.ongoing;
}
private updateBaseInterval(interval: number) {
if (interval > this.options.baseIntervalSlideWindowAvg.max) {
return;
}
this.calledIntervalHistory.push(interval);
if (this.calledIntervalHistory.length > this.options.baseIntervalSlideWindowAvg.maxSize) {
this.calledIntervalHistory.shift();
}
if (this.calledIntervalHistory.length > this.options.baseIntervalSlideWindowAvg.minSize) {
const avg = this.calledIntervalHistory.reduce((a, b) => a + b, 0) / this.calledIntervalHistory.length;
this.baseInterval = clamp(
this.options.baseIntervalSlideWindowAvg.min,
this.options.baseIntervalSlideWindowAvg.max,
avg,
);
}
}
// return score in [0, 1], 1 means the context has a high chance to accept the completion
private calcContextScore(request: CompletionRequest): number {
let score = 0;
const weights = this.options.contextScoreWeights;
const triggerCharacter = request.text[request.position - 1] ?? "";
score += triggerCharacter.match(/^\W*$/) ? weights.triggerCharacter : 0;
const suffix = request.text.slice(request.position) ?? "";
const currentLineInSuffix = splitLines(suffix)[0] ?? "";
score += currentLineInSuffix.match(/^\W*$/) ? weights.noSuffixInCurrentLine : 0;
score += suffix.match(/^\W*$/) ? weights.noSuffix : 0;
score = clamp(0, 1, score);
return score;
}
}

View File

@ -19,6 +19,7 @@ import {
import { Auth } from "./Auth"; import { Auth } from "./Auth";
import { AgentConfig, PartialAgentConfig, defaultAgentConfig, userAgentConfig } from "./AgentConfig"; import { AgentConfig, PartialAgentConfig, defaultAgentConfig, userAgentConfig } from "./AgentConfig";
import { CompletionCache } from "./CompletionCache"; import { CompletionCache } from "./CompletionCache";
import { CompletionDebounce } from "./CompletionDebounce";
import { DataStore } from "./dataStore"; import { DataStore } from "./dataStore";
import { postprocess, preCacheProcess } from "./postprocess"; import { postprocess, preCacheProcess } from "./postprocess";
import { rootLogger, allLoggers } from "./logger"; import { rootLogger, allLoggers } from "./logger";
@ -46,6 +47,7 @@ export class TabbyAgent extends EventEmitter implements Agent {
private auth: Auth; private auth: Auth;
private dataStore: DataStore | null = null; private dataStore: DataStore | null = null;
private completionCache: CompletionCache = new CompletionCache(); private completionCache: CompletionCache = new CompletionCache();
private CompletionDebounce: CompletionDebounce = new CompletionDebounce();
static readonly tryConnectInterval = 1000 * 30; // 30s static readonly tryConnectInterval = 1000 * 30; // 30s
private tryingConnectTimer: ReturnType<typeof setInterval> | null = null; private tryingConnectTimer: ReturnType<typeof setInterval> | null = null;
private completionResponseStats: ResponseStats = new ResponseStats(completionResponseTimeStatsStrategy); private completionResponseStats: ResponseStats = new ResponseStats(completionResponseTimeStatsStrategy);
@ -261,8 +263,8 @@ export class TabbyAgent extends EventEmitter implements Agent {
private createSegments(request: CompletionRequest): { prefix: string; suffix: string } { private createSegments(request: CompletionRequest): { prefix: string; suffix: string } {
// max lines in prefix and suffix configurable // max lines in prefix and suffix configurable
const maxPrefixLines = request.maxPrefixLines ?? this.config.completion.maxPrefixLines; const maxPrefixLines = this.config.completion.prompt.maxPrefixLines;
const maxSuffixLines = request.maxSuffixLines ?? this.config.completion.maxSuffixLines; const maxSuffixLines = this.config.completion.prompt.maxSuffixLines;
const prefix = request.text.slice(0, request.position); const prefix = request.text.slice(0, request.position);
const prefixLines = splitLines(prefix); const prefixLines = splitLines(prefix);
const suffix = request.text.slice(request.position); const suffix = request.text.slice(request.position);
@ -379,7 +381,7 @@ export class TabbyAgent extends EventEmitter implements Agent {
); );
} }
public getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> { public provideCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {}); return cancelable(Promise.reject("Agent is not initialized"), () => {});
} }
@ -387,15 +389,19 @@ export class TabbyAgent extends EventEmitter implements Agent {
return cancelable( return cancelable(
Promise.resolve(null) Promise.resolve(null)
// From cache // From cache
.then((response: CompletionResponse | null) => { .then(async (response: CompletionResponse | null) => {
if (response) return response; if (response) return response;
if (this.completionCache.has(request)) { if (this.completionCache.has(request)) {
this.logger.debug({ request }, "Completion cache hit"); this.logger.debug({ request }, "Completion cache hit");
const debounce = this.CompletionDebounce.debounce(request, this.config.completion.debounce, 0);
cancelableList.push(debounce);
await debounce;
return this.completionCache.get(request); return this.completionCache.get(request);
} }
return null;
}) })
// From api // From api
.then((response: CompletionResponse | null) => { .then(async (response: CompletionResponse | null) => {
if (response) return response; if (response) return response;
const segments = this.createSegments(request); const segments = this.createSegments(request);
if (isBlank(segments.prefix)) { if (isBlank(segments.prefix)) {
@ -405,6 +411,13 @@ export class TabbyAgent extends EventEmitter implements Agent {
choices: [], choices: [],
}; };
} }
const debounce = this.CompletionDebounce.debounce(
request,
this.config.completion.debounce,
this.completionResponseStats.stats()["averageResponseTime"],
);
cancelableList.push(debounce);
await debounce;
const apiRequest = this.callApi( const apiRequest = this.callApi(
this.api.v1.completion, this.api.v1.completion,
{ {
@ -417,17 +430,13 @@ export class TabbyAgent extends EventEmitter implements Agent {
}, },
); );
cancelableList.push(apiRequest); cancelableList.push(apiRequest);
return apiRequest let res = await apiRequest;
.then((response) => { res = await preCacheProcess(request, res);
return preCacheProcess(request, response); this.completionCache.set(request, res);
}) return res;
.then((response) => {
this.completionCache.set(request, response);
return response;
});
}) })
// Postprocess // Postprocess
.then((response: CompletionResponse | null) => { .then(async (response: CompletionResponse | null) => {
return postprocess(request, response); return postprocess(request, response);
}), }),
() => { () => {

View File

@ -6,6 +6,11 @@ export {
AgentEvent, AgentEvent,
StatusChangedEvent, StatusChangedEvent,
ConfigUpdatedEvent, ConfigUpdatedEvent,
AuthRequiredEvent,
NewIssueEvent,
AgentIssue,
SlowCompletionResponseTimeIssue,
HighCompletionTimeoutRateIssue,
CompletionRequest, CompletionRequest,
CompletionResponse, CompletionResponse,
LogEventRequest, LogEventRequest,

View File

@ -10,8 +10,6 @@ export function documentContext(strings): PostprocessContext {
language: null, language: null,
text: doc.replace(/║/, ""), text: doc.replace(/║/, ""),
position: doc.indexOf("║"), position: doc.indexOf("║"),
maxPrefixLines: 20,
maxSuffixLines: 20,
}); });
} }

View File

@ -14,21 +14,13 @@ import {
import { CompletionResponse, CancelablePromise } from "tabby-agent"; import { CompletionResponse, CancelablePromise } from "tabby-agent";
import { agent } from "./agent"; import { agent } from "./agent";
import { notifications } from "./notifications"; import { notifications } from "./notifications";
import { sleep } from "./utils";
export class TabbyCompletionProvider implements InlineCompletionItemProvider { export class TabbyCompletionProvider implements InlineCompletionItemProvider {
private uuid = Date.now();
private latestTimestamp: number = 0;
private pendingCompletion: CancelablePromise<CompletionResponse> | null = null; private pendingCompletion: CancelablePromise<CompletionResponse> | null = null;
// User Settings // User Settings
private enabled: boolean = true; private enabled: boolean = true;
// These settings will be move to tabby-agent
private suggestionDelay: number = 150;
private maxPrefixLines: number = 20;
private maxSuffixLines: number = 20;
constructor() { constructor() {
this.updateConfiguration(); this.updateConfiguration();
workspace.onDidChangeConfiguration((event) => { workspace.onDidChangeConfiguration((event) => {
@ -47,14 +39,6 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider {
return emptyResponse; return emptyResponse;
} }
const currentTimestamp = Date.now();
this.latestTimestamp = currentTimestamp;
await sleep(this.suggestionDelay);
if (currentTimestamp < this.latestTimestamp) {
return emptyResponse;
}
const replaceRange = this.calculateReplaceRange(document, position); const replaceRange = this.calculateReplaceRange(document, position);
if (this.pendingCompletion) { if (this.pendingCompletion) {
@ -67,12 +51,10 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider {
text: document.getText(), text: document.getText(),
position: document.offsetAt(position), position: document.offsetAt(position),
manually: context.triggerKind === InlineCompletionTriggerKind.Invoke, manually: context.triggerKind === InlineCompletionTriggerKind.Invoke,
maxPrefixLines: this.maxPrefixLines,
maxSuffixLines: this.maxSuffixLines,
}; };
this.pendingCompletion = agent().getCompletions(request); this.pendingCompletion = agent().provideCompletions(request);
const completion = await this.pendingCompletion.catch((_: Error) => { const completion = await this.pendingCompletion.catch((e: Error) => {
return null; return null;
}); });
this.pendingCompletion = null; this.pendingCompletion = null;

View File

@ -1,3 +0,0 @@
export function sleep(milliseconds: number) {
return new Promise((r) => setTimeout(r, milliseconds));
}