@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Local Companion bridge for the Dexter Framer plugin — runs Codex or Claude Code on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
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
- trace?.info('event_post_start', {
116
- type,
117
- eventId,
118
- payload: summarizePayload(payload),
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
- durationMs: Date.now() - started,
146
- error: errorMeta(error),
143
+ attempt,
144
+ payload: summarizePayload(payload),
147
145
  });
148
- throw error;
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 = 250,
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
- 250,
1213
- 50,
1214
- 1000,
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
- 250,
1257
- 50,
1258
- 1000,
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 shortText(value, max = 360) {
6
- const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
7
- return text.length > max ? `${text.slice(0, max)}...[truncated ${text.length - max} chars]` : text;
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 compactSchema(schema, depth = 0) {
11
- if (!isRecord(schema) || depth > 4) return schema;
12
- const result = {};
13
- for (const key of ['type', 'enum', 'required', 'additionalProperties']) {
14
- if (schema[key] !== undefined) result[key] = schema[key];
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
- if (isRecord(schema.properties)) {
17
- result.properties = Object.fromEntries(
18
- Object.entries(schema.properties)
19
- .slice(0, 40)
20
- .map(([key, value]) => [key, compactSchema(value, depth + 1)]),
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
- if (schema.items !== undefined) result.items = compactSchema(schema.items, depth + 1);
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: shortText(tool.description || '', 360),
69
- parameters: compactSchema(tool.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":"{\\"key\\":\\"value\\"}"}],"finishReason":"tool_calls|stop|length"}',
111
- 'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
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 availableToolNames = tools.map((tool) => tool.name);
173
- const toolNames =
174
- toolChoice.toolName && availableToolNames.includes(toolChoice.toolName)
175
- ? [toolChoice.toolName]
176
- : availableToolNames;
177
- return {
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: toolNames.length ? 12 : 0,
185
- items: {
186
- type: 'object',
187
- properties: {
188
- id: { type: 'string' },
189
- name: toolNames.length
190
- ? { type: 'string', enum: toolNames }
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
- let toolArguments = call.arguments;
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 decode to an object.`);
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}`,