Skip to content

Commit 83216ae

Browse files
committed
agentHost: preserve concurrent BYOK continuations
1 parent b9fe265 commit 83216ae

2 files changed

Lines changed: 134 additions & 30 deletions

File tree

src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,13 @@ const MAX_PENDING_TOOL_CONTINUATIONS = 256;
7777
type PendingToolCallKind = 'function_call' | 'custom_tool_call';
7878

7979
interface IPendingToolContinuation {
80+
readonly scope: string;
8081
readonly responseId: string;
8182
readonly calls: ReadonlyMap<string, PendingToolCallKind>;
8283
}
8384

8485
/** Provider state awaiting the SDK's immediate tool-result request. */
85-
type ByokLmProxyState = Map<string, IPendingToolContinuation>;
86+
type ByokLmProxyState = Set<IPendingToolContinuation>;
8687

8788
/**
8889
* Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run
@@ -109,7 +110,7 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
109110
}
110111

111112
protected createState(): ByokLmProxyState {
112-
return new Map();
113+
return new Set();
113114
}
114115

115116
async start(): Promise<IByokLmProxyHandle> {
@@ -200,15 +201,13 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
200201
return;
201202
}
202203

203-
const continuationKey = typeof body?.model === 'string' ? this._continuationKey(sessionId, vendor, body.model) : undefined;
204-
let bridgeBody = body;
205-
if (continuationKey && body.previous_response_id === undefined) {
206-
const pending = runtime.state.get(continuationKey);
207-
const input = pending && this._recoverToolContinuation(body.input, pending);
208-
if (input) {
209-
bridgeBody = { ...body, input, previous_response_id: pending.responseId };
210-
}
211-
}
204+
const continuationScope = typeof body?.model === 'string' ? this._continuationScope(sessionId, vendor, body.model) : undefined;
205+
const recovered = continuationScope && body.previous_response_id === undefined
206+
? this._findToolContinuation(runtime.state, continuationScope, body.input)
207+
: undefined;
208+
const bridgeBody = recovered
209+
? { ...body, input: recovered.input, previous_response_id: recovered.pending.responseId }
210+
: body;
212211

213212
let bridgeRequest;
214213
try {
@@ -246,8 +245,11 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
246245
this._writeJsonError(res, 502, result.error, 'api_error');
247246
return;
248247
}
249-
if (continuationKey) {
250-
this._updateToolContinuation(runtime.state, continuationKey, result);
248+
if (recovered) {
249+
runtime.state.delete(recovered.pending);
250+
}
251+
if (continuationScope) {
252+
this._addToolContinuation(runtime.state, continuationScope, result);
251253
}
252254
if (body.stream === true) {
253255
res.writeHead(200, {
@@ -279,10 +281,28 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
279281
}
280282
}
281283

282-
private _continuationKey(sessionId: string, vendor: string, modelId: string): string {
284+
private _continuationScope(sessionId: string, vendor: string, modelId: string): string {
283285
return JSON.stringify([sessionId, vendor, modelId]);
284286
}
285287

288+
private _findToolContinuation(state: ByokLmProxyState, scope: string, input: IResponsesRequest['input']): { readonly pending: IPendingToolContinuation; readonly input: IResponsesRequest['input'] } | undefined {
289+
let match: { readonly pending: IPendingToolContinuation; readonly input: IResponsesRequest['input'] } | undefined;
290+
for (const pending of state) {
291+
if (pending.scope !== scope) {
292+
continue;
293+
}
294+
const recoveredInput = this._recoverToolContinuation(input, pending);
295+
if (!recoveredInput) {
296+
continue;
297+
}
298+
if (match) {
299+
return undefined;
300+
}
301+
match = { pending, input: recoveredInput };
302+
}
303+
return match;
304+
}
305+
286306
private _recoverToolContinuation(input: IResponsesRequest['input'], pending: IPendingToolContinuation): IResponsesRequest['input'] | undefined {
287307
if (!Array.isArray(input)) {
288308
return undefined;
@@ -332,34 +352,28 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
332352
}
333353
}
334354

335-
private _updateToolContinuation(state: ByokLmProxyState, key: string, result: IByokLmChatResult): void {
355+
private _addToolContinuation(state: ByokLmProxyState, scope: string, result: IByokLmChatResult): void {
336356
if (!result.responseId) {
337-
state.delete(key);
338357
return;
339358
}
340359
const calls = new Map<string, PendingToolCallKind>();
341360
for (const item of result.output) {
342361
if (item.type === 'function_call' || item.type === 'custom_tool_call') {
343362
if (!item.callId || calls.has(item.callId)) {
344-
state.delete(key);
345363
return;
346364
}
347365
calls.set(item.callId, item.type);
348366
}
349367
}
350-
if (calls.size) {
351-
// A session can disappear after receiving a tool call, so keep abandoned
352-
// continuations from growing for the lifetime of the shared proxy.
353-
state.delete(key);
354-
state.set(key, { responseId: result.responseId, calls });
355-
if (state.size > MAX_PENDING_TOOL_CONTINUATIONS) {
356-
const oldestKey = state.keys().next().value;
357-
if (oldestKey !== undefined) {
358-
state.delete(oldestKey);
359-
}
368+
if (!calls.size) {
369+
return;
370+
}
371+
state.add({ scope, responseId: result.responseId, calls });
372+
if (state.size > MAX_PENDING_TOOL_CONTINUATIONS) {
373+
const oldest = state.values().next().value;
374+
if (oldest) {
375+
state.delete(oldest);
360376
}
361-
} else {
362-
state.delete(key);
363377
}
364378
}
365379

src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,96 @@ suite('ByokLmProxyService', () => {
422422
});
423423
});
424424

425+
test('keeps interleaved parent and subagent continuations in the same scope', async () => {
426+
const captured: IByokLmChatRequest[] = [];
427+
const parentInitial = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Parent' }] }];
428+
const subagentInitial = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Subagent' }] }];
429+
const replay = (input: object[], callId: string) => [
430+
...input,
431+
{ type: 'function_call', call_id: callId, name: 'tool', arguments: '{}' },
432+
{ type: 'function_call_output', call_id: callId, output: 'done' },
433+
];
434+
435+
await withProxy(
436+
async request => {
437+
captured.push(request);
438+
if (captured.length <= 2) {
439+
const trajectory = captured.length === 1 ? 'parent' : 'subagent';
440+
return {
441+
responseId: `resp_${trajectory}`,
442+
output: [{ type: 'function_call', callId: `call_${trajectory}`, name: 'tool', argumentsJson: '{}' }],
443+
};
444+
}
445+
return { output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }] };
446+
},
447+
async handle => {
448+
for (const input of [
449+
parentInitial,
450+
subagentInitial,
451+
replay(parentInitial, 'call_parent'),
452+
replay(subagentInitial, 'call_subagent'),
453+
]) {
454+
const response = await fetch(responsesUrl(handle, 'acme'), {
455+
method: 'POST',
456+
headers: authHeaders(handle),
457+
body: JSON.stringify({ model: 'm', input }),
458+
});
459+
assert.strictEqual(response.status, 200);
460+
await response.text();
461+
}
462+
},
463+
);
464+
465+
assert.deepStrictEqual(captured.map(request => ({
466+
previousResponseId: request.previousResponseId,
467+
input: request.input,
468+
})), [
469+
{ previousResponseId: undefined, input: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'Parent' }] }] },
470+
{ previousResponseId: undefined, input: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'Subagent' }] }] },
471+
{ previousResponseId: 'resp_parent', input: [{ type: 'function_call_output', callId: 'call_parent', output: 'done' }] },
472+
{ previousResponseId: 'resp_subagent', input: [{ type: 'function_call_output', callId: 'call_subagent', output: 'done' }] },
473+
]);
474+
});
475+
476+
test('preserves full replay when multiple pending responses match', async () => {
477+
const captured: IByokLmChatRequest[] = [];
478+
const replayedInput = [
479+
{ type: 'function_call', call_id: 'call_shared', name: 'tool', arguments: '{}' },
480+
{ type: 'function_call_output', call_id: 'call_shared', output: 'done' },
481+
];
482+
483+
await withProxy(
484+
async request => {
485+
captured.push(request);
486+
return captured.length <= 2
487+
? { responseId: `resp_${captured.length}`, output: [{ type: 'function_call', callId: 'call_shared', name: 'tool', argumentsJson: '{}' }] }
488+
: { output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }] };
489+
},
490+
async handle => {
491+
for (const input of [[], [], replayedInput]) {
492+
const response = await fetch(responsesUrl(handle, 'acme'), {
493+
method: 'POST',
494+
headers: authHeaders(handle),
495+
body: JSON.stringify({ model: 'm', input }),
496+
});
497+
assert.strictEqual(response.status, 200);
498+
await response.text();
499+
}
500+
},
501+
);
502+
503+
assert.deepStrictEqual({
504+
previousResponseId: captured[2]?.previousResponseId,
505+
input: captured[2]?.input,
506+
}, {
507+
previousResponseId: undefined,
508+
input: [
509+
{ type: 'function_call', callId: 'call_shared', name: 'tool', argumentsJson: '{}' },
510+
{ type: 'function_call_output', callId: 'call_shared', output: 'done' },
511+
],
512+
});
513+
});
514+
425515
test('bounds abandoned tool continuations while preserving recent state', async () => {
426516
const captured: IByokLmChatRequest[] = [];
427517
const maximumPendingContinuations = 256;
@@ -450,7 +540,7 @@ suite('ByokLmProxyService', () => {
450540
};
451541

452542
// Sessions can disappear after receiving a tool call. Fill the proxy,
453-
// refresh its oldest entry, then overflow it with one abandoned session.
543+
// add another candidate in its oldest scope, then overflow it.
454544
for (let index = 0; index < maximumPendingContinuations; index++) {
455545
await post(index, []);
456546
}

0 commit comments

Comments
 (0)