@gakim-digital/dexter-bridge 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent.js +74 -38
- package/src/api.js +10 -1
- package/src/config.js +3 -0
- package/src/protocol.js +113 -51
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -108,45 +108,81 @@ function nowIso() {
|
|
|
108
108
|
return new Date().toISOString();
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const TERMINAL_EVENT_TYPES = new Set(['done', 'error']);
|
|
112
|
+
const TERMINAL_EVENT_MAX_ATTEMPTS = 5;
|
|
113
|
+
|
|
114
|
+
function waitForRetry(delayMs) {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function terminalEventRetryDelay(error, attempt) {
|
|
119
|
+
const status = Number(error?.status);
|
|
120
|
+
const retryable =
|
|
121
|
+
!Number.isFinite(status)
|
|
122
|
+
|| status === 408
|
|
123
|
+
|| status === 429
|
|
124
|
+
|| status >= 500;
|
|
125
|
+
if (!retryable) return null;
|
|
126
|
+
if (Number.isFinite(error?.retryAfterMs) && error.retryAfterMs > 0) {
|
|
127
|
+
return Math.min(65_000, Math.max(250, error.retryAfterMs));
|
|
128
|
+
}
|
|
129
|
+
return Math.min(8_000, 500 * (2 ** (attempt - 1)));
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
function createEventPoster({ apiBaseUrl, deviceToken, run, fetchImpl, trace }) {
|
|
112
133
|
return async function send(type, payload = {}) {
|
|
113
134
|
const eventId = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
114
135
|
const started = Date.now();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
const response = await postRunEvent(apiBaseUrl, {
|
|
122
|
-
deviceToken,
|
|
123
|
-
runId: run.runId,
|
|
124
|
-
fetchImpl,
|
|
125
|
-
event: {
|
|
126
|
-
type,
|
|
127
|
-
eventId,
|
|
128
|
-
payload,
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
trace?.info('event_post_done', {
|
|
132
|
-
type,
|
|
133
|
-
eventId,
|
|
134
|
-
durationMs: Date.now() - started,
|
|
135
|
-
accepted: response?.accepted,
|
|
136
|
-
status: response?.status,
|
|
137
|
-
hasToolResult: Boolean(response?.toolResult),
|
|
138
|
-
toolResult: summarizeToolResult(response?.toolResult),
|
|
139
|
-
});
|
|
140
|
-
return response;
|
|
141
|
-
} catch (error) {
|
|
142
|
-
trace?.error('event_post_failed', {
|
|
136
|
+
const maxAttempts = TERMINAL_EVENT_TYPES.has(type)
|
|
137
|
+
? TERMINAL_EVENT_MAX_ATTEMPTS
|
|
138
|
+
: 1;
|
|
139
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
140
|
+
trace?.info('event_post_start', {
|
|
143
141
|
type,
|
|
144
142
|
eventId,
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
attempt,
|
|
144
|
+
payload: summarizePayload(payload),
|
|
147
145
|
});
|
|
148
|
-
|
|
146
|
+
try {
|
|
147
|
+
const response = await postRunEvent(apiBaseUrl, {
|
|
148
|
+
deviceToken,
|
|
149
|
+
runId: run.runId,
|
|
150
|
+
fetchImpl,
|
|
151
|
+
event: {
|
|
152
|
+
type,
|
|
153
|
+
eventId,
|
|
154
|
+
payload,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
trace?.info('event_post_done', {
|
|
158
|
+
type,
|
|
159
|
+
eventId,
|
|
160
|
+
attempt,
|
|
161
|
+
durationMs: Date.now() - started,
|
|
162
|
+
accepted: response?.accepted,
|
|
163
|
+
status: response?.status,
|
|
164
|
+
hasToolResult: Boolean(response?.toolResult),
|
|
165
|
+
toolResult: summarizeToolResult(response?.toolResult),
|
|
166
|
+
});
|
|
167
|
+
return response;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const retryDelayMs =
|
|
170
|
+
attempt < maxAttempts
|
|
171
|
+
? terminalEventRetryDelay(error, attempt)
|
|
172
|
+
: null;
|
|
173
|
+
trace?.error('event_post_failed', {
|
|
174
|
+
type,
|
|
175
|
+
eventId,
|
|
176
|
+
attempt,
|
|
177
|
+
durationMs: Date.now() - started,
|
|
178
|
+
retryDelayMs,
|
|
179
|
+
error: errorMeta(error),
|
|
180
|
+
});
|
|
181
|
+
if (retryDelayMs === null) throw error;
|
|
182
|
+
await waitForRetry(retryDelayMs);
|
|
183
|
+
}
|
|
149
184
|
}
|
|
185
|
+
throw new Error(`Unable to deliver ${type} event.`);
|
|
150
186
|
};
|
|
151
187
|
}
|
|
152
188
|
|
|
@@ -887,7 +923,7 @@ class CompanionRunCancelledError extends Error {
|
|
|
887
923
|
async function callProviderAdapter(adapter, input, {
|
|
888
924
|
send,
|
|
889
925
|
trace,
|
|
890
|
-
controlPollMs =
|
|
926
|
+
controlPollMs = 5_000,
|
|
891
927
|
} = {}) {
|
|
892
928
|
if (!adapter || typeof adapter.runModelTurn !== 'function') {
|
|
893
929
|
throw new Error('The selected provider adapter cannot execute model turns.');
|
|
@@ -1209,9 +1245,9 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
1209
1245
|
trace: options.trace,
|
|
1210
1246
|
controlPollMs: boundedDurationMs(
|
|
1211
1247
|
options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1248
|
+
5_000,
|
|
1249
|
+
1_000,
|
|
1250
|
+
30_000,
|
|
1215
1251
|
),
|
|
1216
1252
|
};
|
|
1217
1253
|
let result;
|
|
@@ -1253,9 +1289,9 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
1253
1289
|
const monitorAbort = new AbortController();
|
|
1254
1290
|
const controlPollMs = boundedDurationMs(
|
|
1255
1291
|
options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1292
|
+
5_000,
|
|
1293
|
+
1_000,
|
|
1294
|
+
30_000,
|
|
1259
1295
|
);
|
|
1260
1296
|
let cliFinished = false;
|
|
1261
1297
|
let cliCancelled = false;
|
package/src/api.js
CHANGED
|
@@ -7,11 +7,12 @@ import {
|
|
|
7
7
|
} from './config.js';
|
|
8
8
|
|
|
9
9
|
export class DexterBridgeApiError extends Error {
|
|
10
|
-
constructor(message, status, body) {
|
|
10
|
+
constructor(message, status, body, retryAfterMs) {
|
|
11
11
|
super(message);
|
|
12
12
|
this.name = 'DexterBridgeApiError';
|
|
13
13
|
this.status = status;
|
|
14
14
|
this.body = body;
|
|
15
|
+
this.retryAfterMs = retryAfterMs;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -50,10 +51,18 @@ export async function requestJson(apiBaseUrl, path, {
|
|
|
50
51
|
});
|
|
51
52
|
const parsed = await response.json().catch(() => null);
|
|
52
53
|
if (!response.ok || parsed?.success === false) {
|
|
54
|
+
const retryAfterHeader = Number(response.headers?.get?.('retry-after'));
|
|
55
|
+
const retryAfterSeconds = Number(parsed?.retryAfterSeconds);
|
|
56
|
+
const retryAfterMs = Number.isFinite(retryAfterHeader) && retryAfterHeader > 0
|
|
57
|
+
? retryAfterHeader * 1000
|
|
58
|
+
: Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
59
|
+
? retryAfterSeconds * 1000
|
|
60
|
+
: undefined;
|
|
53
61
|
throw new DexterBridgeApiError(
|
|
54
62
|
parsed?.error || parsed?.message || `Dexter API request failed with status ${response.status}`,
|
|
55
63
|
response.status,
|
|
56
64
|
parsed,
|
|
65
|
+
retryAfterMs,
|
|
57
66
|
);
|
|
58
67
|
}
|
|
59
68
|
return parsed;
|
package/src/config.js
CHANGED
|
@@ -13,8 +13,10 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
|
13
13
|
'../package.json',
|
|
14
14
|
'./agent.js',
|
|
15
15
|
'./agentOutput.js',
|
|
16
|
+
'./api.js',
|
|
16
17
|
'./cli.js',
|
|
17
18
|
'./config.js',
|
|
19
|
+
'./protocol.js',
|
|
18
20
|
].map((relativePath) => {
|
|
19
21
|
const url = new URL(relativePath, import.meta.url);
|
|
20
22
|
return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
|
|
@@ -23,6 +25,7 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
|
23
25
|
export const BRIDGE_CAPABILITIES = [
|
|
24
26
|
'model-turn-v1',
|
|
25
27
|
'build-fingerprint-v1',
|
|
28
|
+
'tool-schema-parity-v1',
|
|
26
29
|
];
|
|
27
30
|
// Codex is the default local agent: driving Claude Code from a user's Claude.ai
|
|
28
31
|
// subscription needs prior written approval from Anthropic for commercial use, so
|
package/src/protocol.js
CHANGED
|
@@ -1,27 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
function isRecord(value) {
|
|
2
4
|
return value && typeof value === 'object' && !Array.isArray(value);
|
|
3
5
|
}
|
|
4
6
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
function toolCatalogDigest(tools) {
|
|
8
|
+
return createHash('sha256')
|
|
9
|
+
.update(JSON.stringify(tools))
|
|
10
|
+
.digest('hex')
|
|
11
|
+
.slice(0, 24);
|
|
8
12
|
}
|
|
9
13
|
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
export function assertToolCatalogParity(modelTurn = {}) {
|
|
15
|
+
const tools = Array.isArray(modelTurn.tools) ? modelTurn.tools : [];
|
|
16
|
+
const expected = modelTurn?.session?.toolCatalogHash;
|
|
17
|
+
if (!expected) return toolCatalogDigest(tools);
|
|
18
|
+
const actual = toolCatalogDigest(tools);
|
|
19
|
+
if (actual !== expected) {
|
|
20
|
+
throw Object.assign(
|
|
21
|
+
new Error(
|
|
22
|
+
`Dexter tool schema parity failed: expected ${expected}, received ${actual}.`,
|
|
23
|
+
),
|
|
24
|
+
{
|
|
25
|
+
code: 'TOOL_SCHEMA_PARITY_ERROR',
|
|
26
|
+
expectedToolCatalogHash: expected,
|
|
27
|
+
actualToolCatalogHash: actual,
|
|
28
|
+
},
|
|
29
|
+
);
|
|
15
30
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
return actual;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toolParameters(tool) {
|
|
35
|
+
return isRecord(tool?.parameters)
|
|
36
|
+
? tool.parameters
|
|
37
|
+
: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nestedToolParameters(schema, namespace, outputDefinitions) {
|
|
45
|
+
const source = JSON.parse(JSON.stringify(schema));
|
|
46
|
+
delete source.$schema;
|
|
47
|
+
const localDefinitions = {
|
|
48
|
+
...(isRecord(source.definitions) ? source.definitions : {}),
|
|
49
|
+
...(isRecord(source.$defs) ? source.$defs : {}),
|
|
50
|
+
};
|
|
51
|
+
delete source.definitions;
|
|
52
|
+
delete source.$defs;
|
|
53
|
+
const prefix = `${namespace}__`;
|
|
54
|
+
const rewrite = (value) => {
|
|
55
|
+
if (Array.isArray(value)) return value.map(rewrite);
|
|
56
|
+
if (!isRecord(value)) return value;
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(value).map(([key, nested]) => {
|
|
59
|
+
if (key === '$ref' && typeof nested === 'string') {
|
|
60
|
+
const match = nested.match(/^#\/(?:definitions|\$defs)\/(.+)$/);
|
|
61
|
+
if (match) return [key, `#/$defs/${prefix}${match[1]}`];
|
|
62
|
+
}
|
|
63
|
+
return [key, rewrite(nested)];
|
|
64
|
+
}),
|
|
21
65
|
);
|
|
66
|
+
};
|
|
67
|
+
for (const [name, definition] of Object.entries(localDefinitions)) {
|
|
68
|
+
outputDefinitions[`${prefix}${name}`] = rewrite(definition);
|
|
22
69
|
}
|
|
23
|
-
|
|
24
|
-
return result;
|
|
70
|
+
return rewrite(source);
|
|
25
71
|
}
|
|
26
72
|
|
|
27
73
|
function compactModelTurnContent(content) {
|
|
@@ -65,8 +111,8 @@ export function compactToolCatalog(tools = []) {
|
|
|
65
111
|
.slice(0, 40)
|
|
66
112
|
.map((tool) => ({
|
|
67
113
|
name: typeof tool.name === 'string' ? tool.name : '',
|
|
68
|
-
description:
|
|
69
|
-
parameters:
|
|
114
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
115
|
+
parameters: toolParameters(tool),
|
|
70
116
|
}))
|
|
71
117
|
.filter((tool) => tool.name);
|
|
72
118
|
}
|
|
@@ -107,8 +153,8 @@ function modelTurnInstructions(modelTurn = {}) {
|
|
|
107
153
|
'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
|
|
108
154
|
'Return exactly one JSON object and no markdown.',
|
|
109
155
|
'Allowed response:',
|
|
110
|
-
'{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":
|
|
111
|
-
'Each toolCalls[].arguments value must be
|
|
156
|
+
'{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":{"key":"value"}}],"finishReason":"tool_calls|stop|length"}',
|
|
157
|
+
'Each toolCalls[].arguments value must be an object matching the exact selected tool schema.',
|
|
112
158
|
'Use only tools listed below. Do not claim a tool executed; only request it.',
|
|
113
159
|
toolChoice.required
|
|
114
160
|
? toolChoice.toolName
|
|
@@ -119,6 +165,7 @@ function modelTurnInstructions(modelTurn = {}) {
|
|
|
119
165
|
}
|
|
120
166
|
|
|
121
167
|
export function buildModelTurnPrompt(modelTurn = {}) {
|
|
168
|
+
assertToolCatalogParity(modelTurn);
|
|
122
169
|
const messages = compactMessages(modelTurn.messages, 64);
|
|
123
170
|
const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
|
|
124
171
|
return [
|
|
@@ -131,6 +178,7 @@ export function buildModelTurnPrompt(modelTurn = {}) {
|
|
|
131
178
|
}
|
|
132
179
|
|
|
133
180
|
export function buildModelTurnDeltaPrompt(modelTurn = {}) {
|
|
181
|
+
assertToolCatalogParity(modelTurn);
|
|
134
182
|
const messages = compactMessages(modelTurn.messages, 24);
|
|
135
183
|
const includeTools = modelTurn?.session?.toolCatalogChanged === true;
|
|
136
184
|
const tools = includeTools
|
|
@@ -167,37 +215,56 @@ export function buildModelTurnFallbackPrompt(modelTurn = {}) {
|
|
|
167
215
|
}
|
|
168
216
|
|
|
169
217
|
export function modelTurnOutputSchema(modelTurn = {}) {
|
|
218
|
+
assertToolCatalogParity(modelTurn);
|
|
170
219
|
const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
|
|
171
220
|
const toolChoice = normalizedToolChoice(modelTurn);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
221
|
+
const selectedTools =
|
|
222
|
+
toolChoice.toolName
|
|
223
|
+
? tools.filter((tool) => tool.name === toolChoice.toolName)
|
|
224
|
+
: tools;
|
|
225
|
+
const definitions = {};
|
|
226
|
+
const toolCallVariants = selectedTools.map((tool, index) => ({
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: {
|
|
229
|
+
id: { type: 'string' },
|
|
230
|
+
name: { type: 'string', enum: [tool.name] },
|
|
231
|
+
arguments: nestedToolParameters(
|
|
232
|
+
tool.parameters,
|
|
233
|
+
`tool_${index}_${tool.name.replace(/[^A-Za-z0-9_]/g, '_')}`,
|
|
234
|
+
definitions,
|
|
235
|
+
),
|
|
236
|
+
},
|
|
237
|
+
required: ['id', 'name', 'arguments'],
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
}));
|
|
240
|
+
const emptyToolCall = {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
id: { type: 'string' },
|
|
244
|
+
name: { type: 'string' },
|
|
245
|
+
arguments: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {},
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
required: ['id', 'name', 'arguments'],
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
};
|
|
254
|
+
const output = {
|
|
178
255
|
type: 'object',
|
|
179
256
|
properties: {
|
|
180
257
|
text: { type: 'string' },
|
|
181
258
|
toolCalls: {
|
|
182
259
|
type: 'array',
|
|
183
260
|
...(toolChoice.required ? { minItems: 1 } : {}),
|
|
184
|
-
maxItems:
|
|
185
|
-
items:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
: { type: 'string' },
|
|
192
|
-
// Structured Outputs requires every object schema to declare
|
|
193
|
-
// additionalProperties:false. Tool argument shapes differ per
|
|
194
|
-
// selected tool, so encode them at this boundary and validate the
|
|
195
|
-
// decoded object before returning the completion to Dexter.
|
|
196
|
-
arguments: { type: 'string' },
|
|
197
|
-
},
|
|
198
|
-
required: ['id', 'name', 'arguments'],
|
|
199
|
-
additionalProperties: false,
|
|
200
|
-
},
|
|
261
|
+
maxItems: selectedTools.length ? 12 : 0,
|
|
262
|
+
items:
|
|
263
|
+
toolCallVariants.length === 1
|
|
264
|
+
? toolCallVariants[0]
|
|
265
|
+
: toolCallVariants.length > 1
|
|
266
|
+
? { anyOf: toolCallVariants }
|
|
267
|
+
: emptyToolCall,
|
|
201
268
|
},
|
|
202
269
|
finishReason: {
|
|
203
270
|
type: 'string',
|
|
@@ -209,6 +276,8 @@ export function modelTurnOutputSchema(modelTurn = {}) {
|
|
|
209
276
|
required: ['text', 'toolCalls', 'finishReason'],
|
|
210
277
|
additionalProperties: false,
|
|
211
278
|
};
|
|
279
|
+
if (Object.keys(definitions).length) output.$defs = definitions;
|
|
280
|
+
return output;
|
|
212
281
|
}
|
|
213
282
|
|
|
214
283
|
export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-companion') {
|
|
@@ -221,16 +290,9 @@ export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-compani
|
|
|
221
290
|
if (!isRecord(call) || typeof call.name !== 'string' || !call.name.trim()) {
|
|
222
291
|
throw new Error(`Model turn tool call ${index + 1} is missing a name.`);
|
|
223
292
|
}
|
|
224
|
-
|
|
225
|
-
if (typeof toolArguments === 'string') {
|
|
226
|
-
try {
|
|
227
|
-
toolArguments = JSON.parse(toolArguments);
|
|
228
|
-
} catch {
|
|
229
|
-
throw new Error(`Model turn tool call ${index + 1} arguments must contain valid JSON.`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
293
|
+
const toolArguments = call.arguments;
|
|
232
294
|
if (toolArguments !== undefined && !isRecord(toolArguments)) {
|
|
233
|
-
throw new Error(`Model turn tool call ${index + 1} arguments must
|
|
295
|
+
throw new Error(`Model turn tool call ${index + 1} arguments must be an object.`);
|
|
234
296
|
}
|
|
235
297
|
return {
|
|
236
298
|
id: typeof call.id === 'string' && call.id.trim() ? call.id.slice(0, 160) : `local_tool_${index + 1}`,
|