Skip to content

Commit fe6f5b4

Browse files
committed
agentHost: preserve BYOK state through tool continuations
1 parent 225afcc commit fe6f5b4

3 files changed

Lines changed: 221 additions & 43 deletions

File tree

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

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type * as http from 'http';
77
import { createDecorator } from '../../../instantiation/common/instantiation.js';
88
import { ILogService } from '../../../log/common/log.js';
9+
import type { IByokLmChatResult } from '../../common/agentHostByokLm.js';
910
import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js';
1011
import { parseProxyBearer } from '../claude/claudeProxyAuth.js';
1112
import {
@@ -72,12 +73,15 @@ const PROXY_USER_FACING_NAME = 'ByokLmProxyService';
7273
const VENDOR_PATH_PREFIX = '/v/';
7374
const RESPONSES_SUFFIX = '/responses';
7475

75-
/**
76-
* The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is
77-
* resolved from {@link IByokLmBridgeRegistry} at request time, and the nonce
78-
* lives on the runtime owned by {@link LoopbackProxyServer}.
79-
*/
80-
type ByokLmProxyState = undefined;
76+
type PendingToolCallKind = 'function_call' | 'custom_tool_call';
77+
78+
interface IPendingToolContinuation {
79+
readonly responseId: string;
80+
readonly calls: ReadonlyMap<string, PendingToolCallKind>;
81+
}
82+
83+
/** Provider state awaiting the SDK's immediate tool-result request. */
84+
type ByokLmProxyState = Map<string, IPendingToolContinuation>;
8185

8286
/**
8387
* Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run
@@ -104,8 +108,7 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
104108
}
105109

106110
protected createState(): ByokLmProxyState {
107-
// No per-bind state — the bridge is resolved from the registry per request.
108-
return undefined;
111+
return new Map();
109112
}
110113

111114
async start(): Promise<IByokLmProxyHandle> {
@@ -145,14 +148,15 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
145148
// Inbound requests carry `Bearer <nonce>.<sessionId>`; the runtime is
146149
// handed `<nonce>.<sessionId>` at session launch.
147150
const auth = parseProxyBearer(req.headers, runtime.nonce);
148-
if (!auth.valid || !auth.sessionId) {
151+
const sessionId = auth.sessionId;
152+
if (!auth.valid || !sessionId) {
149153
this._writeJsonError(res, 401, 'Invalid authentication', 'authentication_error');
150154
return;
151155
}
152156

153157
const vendor = this._parseVendorFromResponsesPath(pathname);
154158
if (method === 'POST' && vendor !== undefined) {
155-
await this._handleResponses(req, res, runtime, vendor);
159+
await this._handleResponses(req, res, runtime, vendor, sessionId);
156160
return;
157161
}
158162

@@ -185,7 +189,7 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
185189
return vendor;
186190
}
187191

188-
private async _handleResponses(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime<ByokLmProxyState>, vendor: string): Promise<void> {
192+
private async _handleResponses(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime<ByokLmProxyState>, vendor: string, sessionId: string): Promise<void> {
189193
let body: IResponsesRequest;
190194
try {
191195
const raw = await readProxyRequestBody(req);
@@ -195,9 +199,19 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
195199
return;
196200
}
197201

202+
const continuationKey = typeof body?.model === 'string' ? this._continuationKey(sessionId, vendor, body.model) : undefined;
203+
let bridgeBody = body;
204+
if (continuationKey && body.previous_response_id === undefined) {
205+
const pending = runtime.state.get(continuationKey);
206+
const input = pending && this._recoverToolContinuation(body.input, pending);
207+
if (input) {
208+
bridgeBody = { ...body, input, previous_response_id: pending.responseId };
209+
}
210+
}
211+
198212
let bridgeRequest;
199213
try {
200-
bridgeRequest = responsesRequestToBridge(vendor, body);
214+
bridgeRequest = responsesRequestToBridge(vendor, bridgeBody);
201215
} catch (err) {
202216
const message = err instanceof ResponsesTranslationError ? err.message : String(err);
203217
this._writeJsonError(res, 400, message, 'invalid_request_error');
@@ -231,6 +245,9 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
231245
this._writeJsonError(res, 502, result.error, 'api_error');
232246
return;
233247
}
248+
if (continuationKey) {
249+
this._updateToolContinuation(runtime.state, continuationKey, result);
250+
}
234251
if (body.stream === true) {
235252
res.writeHead(200, {
236253
'Content-Type': 'text/event-stream',
@@ -261,6 +278,81 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
261278
}
262279
}
263280

281+
private _continuationKey(sessionId: string, vendor: string, modelId: string): string {
282+
return JSON.stringify([sessionId, vendor, modelId]);
283+
}
284+
285+
private _recoverToolContinuation(input: IResponsesRequest['input'], pending: IPendingToolContinuation): IResponsesRequest['input'] | undefined {
286+
if (!Array.isArray(input)) {
287+
return undefined;
288+
}
289+
290+
// A previous_response_id request must contain only the new tool outputs.
291+
let start = input.length;
292+
while (start > 0 && this._toolOutputKind((input[start - 1] as { readonly type?: unknown } | null)?.type)) {
293+
start--;
294+
}
295+
if (input.length - start !== pending.calls.size) {
296+
return undefined;
297+
}
298+
299+
const outputs = input.slice(start);
300+
const seen = new Set<string>();
301+
for (const value of outputs) {
302+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
303+
return undefined;
304+
}
305+
const item = value as { readonly type?: unknown; readonly call_id?: unknown; readonly output?: unknown };
306+
const kind = this._toolOutputKind(item.type);
307+
const callId = item.call_id;
308+
if (
309+
!kind
310+
|| typeof callId !== 'string'
311+
|| !callId
312+
|| seen.has(callId)
313+
|| pending.calls.get(callId) !== kind
314+
|| (item.output !== undefined && typeof item.output !== 'string')
315+
) {
316+
return undefined;
317+
}
318+
seen.add(callId);
319+
}
320+
return outputs;
321+
}
322+
323+
private _toolOutputKind(type: unknown): PendingToolCallKind | undefined {
324+
switch (type) {
325+
case 'function_call_output':
326+
return 'function_call';
327+
case 'custom_tool_call_output':
328+
return 'custom_tool_call';
329+
default:
330+
return undefined;
331+
}
332+
}
333+
334+
private _updateToolContinuation(state: ByokLmProxyState, key: string, result: IByokLmChatResult): void {
335+
if (!result.responseId) {
336+
state.delete(key);
337+
return;
338+
}
339+
const calls = new Map<string, PendingToolCallKind>();
340+
for (const item of result.output) {
341+
if (item.type === 'function_call' || item.type === 'custom_tool_call') {
342+
if (!item.callId || calls.has(item.callId)) {
343+
state.delete(key);
344+
return;
345+
}
346+
calls.set(item.callId, item.type);
347+
}
348+
}
349+
if (calls.size) {
350+
state.set(key, { responseId: result.responseId, calls });
351+
} else {
352+
state.delete(key);
353+
}
354+
}
355+
264356
private _writeJsonError(res: http.ServerResponse, status: number, message: string, type = 'api_error'): void {
265357
if (res.headersSent || res.writableEnded) {
266358
return;

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

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ suite('ByokLmProxyService', () => {
5757
return `${handle.providerBaseUrl(vendor)}/responses`;
5858
}
5959

60-
function authHeaders(handle: IByokLmProxyHandle): Record<string, string> {
61-
return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}.${sessionId}` };
60+
function authHeaders(handle: IByokLmProxyHandle, selectedSessionId = sessionId): Record<string, string> {
61+
return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}.${selectedSessionId}` };
6262
}
6363

6464
test('serves the unauthenticated health check', async () => {
@@ -298,6 +298,88 @@ suite('ByokLmProxyService', () => {
298298
]);
299299
});
300300

301+
test('recovers only the exact scoped tool continuation without overriding explicit state', async () => {
302+
const captured: IByokLmChatRequest[] = [];
303+
const initialInput = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Use both tools.' }] }];
304+
const outputs = [
305+
{ type: 'function_call_output', call_id: 'function_1', output: 'Rain' },
306+
{ type: 'custom_tool_call_output', call_id: 'custom_1', output: 'Applied patch.' },
307+
];
308+
const replayedInput = [
309+
...initialInput,
310+
{ type: 'function_call', call_id: 'function_1', name: 'get_weather', arguments: '{}' },
311+
{ type: 'custom_tool_call', call_id: 'custom_1', name: 'apply_patch', input: '*** Begin Patch\n*** End Patch' },
312+
...outputs,
313+
];
314+
315+
await withProxy(
316+
async request => {
317+
captured.push(request);
318+
if (captured.length === 1) {
319+
return {
320+
responseId: 'resp_provider_1',
321+
output: [
322+
{ type: 'function_call', callId: 'function_1', name: 'get_weather', argumentsJson: '{}' },
323+
{ type: 'custom_tool_call', callId: 'custom_1', name: 'apply_patch', input: '*** Begin Patch\n*** End Patch' },
324+
],
325+
};
326+
}
327+
if (captured.length === 6) {
328+
return { responseId: 'resp_provider_2', output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }] };
329+
}
330+
return { output: [], error: 'not a valid continuation' };
331+
},
332+
async handle => {
333+
const post = (vendor: string, model: string, requestSessionId: string, input: unknown, previousResponseId?: string) => fetch(responsesUrl(handle, vendor), {
334+
method: 'POST',
335+
headers: authHeaders(handle, requestSessionId),
336+
body: JSON.stringify({ model, input, ...(previousResponseId ? { previous_response_id: previousResponseId } : {}) }),
337+
});
338+
339+
let response = await post('acme', 'm', sessionId, initialInput);
340+
assert.strictEqual(response.status, 200);
341+
await response.text();
342+
343+
response = await post('acme', 'm', sessionId, replayedInput, 'resp_explicit');
344+
assert.strictEqual(response.status, 502);
345+
await response.text();
346+
347+
for (const [vendor, model, requestSessionId] of [
348+
['acme', 'm', 'sess-2'],
349+
['other', 'm', sessionId],
350+
['acme', 'other-model', sessionId],
351+
]) {
352+
response = await post(vendor, model, requestSessionId, replayedInput);
353+
assert.strictEqual(response.status, 502);
354+
await response.text();
355+
}
356+
357+
response = await post('acme', 'm', sessionId, replayedInput);
358+
assert.strictEqual(response.status, 200);
359+
await response.text();
360+
361+
response = await post('acme', 'm', sessionId, replayedInput);
362+
assert.strictEqual(response.status, 502);
363+
await response.text();
364+
},
365+
);
366+
367+
assert.strictEqual(captured[1]?.previousResponseId, 'resp_explicit');
368+
assert.deepStrictEqual(captured.slice(2, 5).map(request => request.previousResponseId), [undefined, undefined, undefined]);
369+
assert.deepStrictEqual({
370+
previousResponseId: captured[5]?.previousResponseId,
371+
input: captured[5]?.input,
372+
clearedPreviousResponseId: captured[6]?.previousResponseId,
373+
}, {
374+
previousResponseId: 'resp_provider_1',
375+
input: [
376+
{ type: 'function_call_output', callId: 'function_1', output: 'Rain' },
377+
{ type: 'custom_tool_call_output', callId: 'custom_1', output: 'Applied patch.' },
378+
],
379+
clearedPreviousResponseId: undefined,
380+
});
381+
});
382+
301383
test('decodes a url-encoded vendor path segment', async () => {
302384
let captured: IByokLmChatRequest | undefined;
303385
await withProxy(

‎src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts‎

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import assert from 'assert';
77
import { mkdtemp, rm } from 'fs/promises';
88
import { tmpdir } from 'os';
9-
import { CopilotClient } from '@github/copilot-sdk';
9+
import { CopilotClient, defineTool } from '@github/copilot-sdk';
1010
import { Emitter } from '../../../../../base/common/event.js';
1111
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
1212
import { NullLogService } from '../../../../log/common/log.js';
@@ -20,7 +20,7 @@ suite('Agent Host Provider Integration - Copilot BYOK Responses', function () {
2020

2121
const store = ensureNoDisposablesAreLeakedInTestSuite();
2222

23-
test('bundled SDK consumes structured reasoning and text from the proxy', async function () {
23+
test('bundled SDK preserves provider state through a tool continuation', async function () {
2424
this.timeout(120_000);
2525

2626
const sessionId = 'byok-responses-integration';
@@ -31,19 +31,18 @@ suite('Agent Host Provider Integration - Copilot BYOK Responses', function () {
3131
const registration = registry.register('client', {
3232
chat: async request => {
3333
captured.push(request);
34-
if (captured.length > 1) {
34+
if (captured.length === 1) {
3535
return {
36-
responseId: 'resp_provider_2',
37-
output: [{ type: 'message', content: [{ type: 'text', text: 'second' }] }],
36+
responseId: 'resp_provider_1',
37+
output: [
38+
{ type: 'reasoning', id: 'rs_provider', summary: ['Calling echo'], encryptedContent: 'opaque' },
39+
{ type: 'function_call', callId: 'call_1', name: 'echo', argumentsJson: '{}' },
40+
],
3841
};
3942
}
4043
return {
41-
responseId: 'resp_provider',
42-
output: [
43-
{ type: 'reasoning', id: 'rs_provider', summary: ['considered options'], encryptedContent: 'opaque' },
44-
{ type: 'message', content: [{ type: 'text', text: 'hello' }] },
45-
],
46-
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 1 },
44+
responseId: 'resp_provider_2',
45+
output: [{ type: 'message', content: [{ type: 'text', text: 'final response' }] }],
4746
};
4847
},
4948
onDidChangeModels: models.event,
@@ -69,47 +68,52 @@ suite('Agent Host Provider Integration - Copilot BYOK Responses', function () {
6968
sessionId,
7069
model: 'test-model',
7170
reasoningEffort: 'medium',
72-
availableTools: [],
71+
tools: [
72+
defineTool('echo', {
73+
description: 'Returns a fixed echo result.',
74+
parameters: { type: 'object', properties: {}, additionalProperties: false },
75+
handler: async () => 'echo result',
76+
skipPermission: true,
77+
defer: 'never',
78+
}),
79+
],
80+
availableTools: ['custom:echo'],
7381
provider: {
7482
type: 'openai',
7583
wireApi: 'responses',
7684
baseUrl: handle.providerBaseUrl('acme'),
7785
bearerToken: `${handle.nonce}.${sessionId}`,
7886
},
7987
});
80-
const reasoning: string[] = [];
81-
session.on('assistant.reasoning', event => reasoning.push(event.data.content));
82-
83-
const result = await session.sendAndWait({ prompt: 'Reply exactly hello.' }, 30_000);
84-
const secondResult = await session.sendAndWait({ prompt: 'Reply exactly second.' }, 30_000);
85-
const replayedReasoning = captured[1]?.input.find(item => item.type === 'reasoning');
88+
const result = await session.sendAndWait({ prompt: 'Call echo once, then reply exactly final response.' }, 30_000);
8689

8790
assert.deepStrictEqual({
8891
result: result?.type === 'assistant.message' ? result.data.content : undefined,
89-
secondResult: secondResult?.type === 'assistant.message' ? secondResult.data.content : undefined,
90-
reasoning,
92+
requestCount: captured.length,
9193
firstRequest: {
9294
vendor: captured[0]?.vendor,
9395
modelId: captured[0]?.modelId,
9496
inputTypes: captured[0]?.input.map(item => item.type),
9597
reasoningEffort: captured[0]?.reasoningEffort,
9698
},
97-
replayedReasoning,
99+
secondRequest: {
100+
previousResponseId: captured[1]?.previousResponseId,
101+
input: captured[1]?.input.map(item => item.type === 'function_call_output'
102+
? { type: item.type, callId: item.callId, output: item.output }
103+
: { type: item.type }),
104+
},
98105
}, {
99-
result: 'hello',
100-
secondResult: 'second',
101-
reasoning: ['considered options'],
106+
result: 'final response',
107+
requestCount: 2,
102108
firstRequest: {
103109
vendor: 'acme',
104110
modelId: 'test-model',
105111
inputTypes: ['message'],
106112
reasoningEffort: 'medium',
107113
},
108-
replayedReasoning: {
109-
type: 'reasoning',
110-
id: 'rs_provider',
111-
summary: ['considered options'],
112-
encryptedContent: 'opaque',
114+
secondRequest: {
115+
previousResponseId: 'resp_provider_1',
116+
input: [{ type: 'function_call_output', callId: 'call_1', output: 'echo result' }],
113117
},
114118
});
115119

0 commit comments

Comments
 (0)