dsh-cherry-bridge 0.1.9 → 0.1.10

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/src/tools.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from './defaults.js'
13
13
  import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
14
14
  import { ContentReadGate } from './contentGate.js'
15
+ import { truncateErrorMessage } from './diagnostics.js'
15
16
  import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
16
17
  import { PairingError } from './pairing.js'
17
18
  import type { PairingErrorKind } from './pairing.js'
@@ -299,12 +300,81 @@ export interface CherryReadPart {
299
300
  byteLength?: string
300
301
  }
301
302
 
303
+ /**
304
+ * The peer's verdict on a FAILED message, narrowed to what a caller can act on.
305
+ *
306
+ * The wire carries more (`failure.failure.source`, `context.responseBody`, the
307
+ * provider's status code); none of it is projected. `cherry_read` is a text
308
+ * channel for the conversation, and the provider's raw error body is neither
309
+ * conversation nor the caller's business — the peer already classified the
310
+ * cause into `reasonCode`, which is the actionable half.
311
+ */
312
+ export interface CherryReadFailure {
313
+ /**
314
+ * The peer's classification (`provider_unavailable`, `rate_limit`,
315
+ * `context_length`, … — the protocol's versioned vocabulary).
316
+ *
317
+ * Relayed as a SHAPE-checked string rather than matched against a closed
318
+ * enum: the vocabulary belongs to the peer and grows with it, and relabelling
319
+ * a new code `'unknown'` would hide a real cause behind a value that means
320
+ * "the peer could not classify". Absent or unusable values become
321
+ * `'unknown'`, which is exactly what the contract uses for that case.
322
+ */
323
+ reasonCode: string
324
+ /** Whether the peer judged a retry of the same work worthwhile. */
325
+ retryable: boolean
326
+ /**
327
+ * The peer's one-line explanation, bounded by the diagnostics error cap
328
+ * (200 characters) with a `…[truncated]` marker when it was cut.
329
+ */
330
+ message: string
331
+ }
332
+
333
+ /** `provider_unavailable`-shaped: lowercase words separated by underscores. */
334
+ const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/
335
+
336
+ /**
337
+ * Projects the wire's `failure` object (present on `status: 'error'` messages)
338
+ * into {@link CherryReadFailure}, or `undefined` when there is none.
339
+ *
340
+ * Deliberately tolerant of a malformed payload: a peer that sends
341
+ * `failure: { retryable: false }` with no nested record still yields a usable
342
+ * verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
343
+ * read — the message's TEXT is already in hand, and losing it over a metadata
344
+ * field would trade the content for the annotation. A degenerate object
345
+ * (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
346
+ * empty one, and saying so is more honest than reporting "no verdict". Only an
347
+ * ABSENT or non-object `failure` yields `undefined`.
348
+ */
349
+ export function projectMessageFailure(raw: Record<string, unknown>): CherryReadFailure | undefined {
350
+ const failure = raw['failure']
351
+ if (typeof failure !== 'object' || failure === null) return undefined
352
+ const record = failure as Record<string, unknown>
353
+ const nested = record['failure']
354
+ const detail = typeof nested === 'object' && nested !== null ? (nested as Record<string, unknown>) : {}
355
+ const rawReason = detail['reasonCode']
356
+ const reasonCode =
357
+ typeof rawReason === 'string' && REASON_CODE_SHAPE.test(rawReason) ? rawReason : 'unknown'
358
+ const rawMessage = record['message']
359
+ return {
360
+ reasonCode,
361
+ retryable: record['retryable'] === true,
362
+ message: truncateErrorMessage(typeof rawMessage === 'string' ? rawMessage : ''),
363
+ }
364
+ }
365
+
302
366
  /** One message in a `cherry_read` snapshot. */
303
367
  export interface CherryReadMessage {
304
368
  messageId: string
305
369
  revision: string
306
370
  role: string
307
371
  status: string
372
+ /**
373
+ * Present exactly when the peer sent a `failure` for this message (in
374
+ * practice: `status: 'error'`). A successful message never carries it, so a
375
+ * caller can test for the key rather than compare `status` strings.
376
+ */
377
+ failure?: CherryReadFailure
308
378
  parts: CherryReadPart[]
309
379
  /**
310
380
  * Present only when this message's parts could NOT all be read: the safety
@@ -1215,6 +1285,87 @@ function withIdempotencyHint(error: unknown): unknown {
1215
1285
  return error
1216
1286
  }
1217
1287
 
1288
+ /**
1289
+ * The phrase this hint leaves behind, so applying it twice reads once. A phrase
1290
+ * from the ADVICE rather than the advice itself, because the wait clause varies
1291
+ * with the peer's `retryAfterMs`; matching the peer's own text would match every
1292
+ * rate-limit refusal, hinted or not.
1293
+ */
1294
+ const RATE_LIMIT_HINT_MARK = 'refuses BEFORE dispatch'
1295
+
1296
+ /**
1297
+ * How long a refused COMMAND was asked to wait, or undefined when the peer sent
1298
+ * nothing a caller can act on.
1299
+ *
1300
+ * `<= 0` counts as absent rather than "retry immediately": a zero delay is
1301
+ * noise in an error message, and the desktop clamps its own value to `>= 1`
1302
+ * (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
1303
+ * reported as a delay shorter than the one the peer asked for.
1304
+ */
1305
+ function rateLimitedWaitMs(error: Error): number | undefined {
1306
+ if (!(error instanceof JsonRpcRequestError)) return undefined
1307
+ const data = error.data
1308
+ if (!isRecord(data)) return undefined
1309
+ const raw = data['retryAfterMs']
1310
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return undefined
1311
+ return Math.ceil(raw)
1312
+ }
1313
+
1314
+ /**
1315
+ * The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
1316
+ * >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
1317
+ *
1318
+ * Two facts decide the retry and NEITHER survives the tool boundary on its own:
1319
+ *
1320
+ * - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
1321
+ * gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
1322
+ * so, unlike a reply lost on a dead link, this is a certain non-outcome.
1323
+ * That is what makes a retry safe, and it is why the SAME `commandId` may be
1324
+ * reused: nothing reached the peer's command ledger, so no
1325
+ * `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
1326
+ * attempt, not "the command", because an earlier attempt under the same
1327
+ * `commandId` may itself have lost its reply after dispatch: the retry advice
1328
+ * is right in that sequence too (the ledger replays the original receipt),
1329
+ * but "never landed" would be a claim about a run this error knows nothing
1330
+ * of.
1331
+ * - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
1332
+ * own 1 s window and already on the error we are holding. The wait clause
1333
+ * comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
1334
+ * retry" and act on it without waiting.
1335
+ *
1336
+ * The cause is named because at the DEFAULT pacing this refusal is unreachable:
1337
+ * the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
1338
+ * accepted connection) and we pace 32/s shared / 256/s content against its
1339
+ * 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
1340
+ * normally means the configured budget is above the peer's allowance — the knob
1341
+ * to look at, not a transient blip — and a caller told to "retry later" without
1342
+ * that pointer would keep retrying into the same wall. Only the 256/s figure is
1343
+ * quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
1344
+ * the one an operator has to compare against.
1345
+ *
1346
+ * Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
1347
+ * the `data` and the `JsonRpcRequestError` identity are the peer's, and one
1348
+ * command is still never replayed (the advice is for the CALLER's retry).
1349
+ */
1350
+ export function withRateLimitHint(error: unknown): unknown {
1351
+ if (!(error instanceof Error)) return error
1352
+ if (failureReason(error) !== 'RATE_LIMITED') return error
1353
+ if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error
1354
+ const waitMs = rateLimitedWaitMs(error)
1355
+ const advice = [
1356
+ 'this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its ' +
1357
+ 'command ledger',
1358
+ waitMs === undefined
1359
+ ? 'A retry is safe, and it may reuse the SAME commandId'
1360
+ : `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may ` +
1361
+ 'reuse the SAME commandId',
1362
+ 'The default budget (32/s) does not come close to the desktop\'s allowance, so a refusal ' +
1363
+ 'normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)',
1364
+ ]
1365
+ error.message = `${error.message} — ${advice.join('. ')}.`
1366
+ return error
1367
+ }
1368
+
1218
1369
  function optionalString(value: unknown): string | undefined {
1219
1370
  return typeof value === 'string' && value !== '' ? value : undefined
1220
1371
  }
@@ -2450,7 +2601,11 @@ class CherryToolEngine {
2450
2601
  'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
2451
2602
  'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
2452
2603
  'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
2453
- 'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The ' +
2604
+ 'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A ' +
2605
+ 'failed message (`status: "error"`) may carry `failure: {reasonCode, retryable, ' +
2606
+ 'message}` when the peer sent one — its classification, whether it judged a retry ' +
2607
+ 'worthwhile, and a bounded summary; the peer\'s `source` and `context.responseBody` ' +
2608
+ 'are never projected. The ' +
2454
2609
  'answer is ALSO bounded by an output budget: `truncated` with `truncationReason: ' +
2455
2610
  '"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
2456
2611
  'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
@@ -2779,11 +2934,13 @@ class CherryToolEngine {
2779
2934
  // stays absent rather than claiming a zero.
2780
2935
  const unavailableParts = parts.filter((part) => part.unavailable !== undefined).length
2781
2936
  const incomplete = unavailableParts > 0 || partsTruncated !== undefined
2937
+ const failure = projectMessageFailure(raw)
2782
2938
  return {
2783
2939
  messageId,
2784
2940
  revision,
2785
2941
  role: String(raw['role'] ?? ''),
2786
2942
  status: String(raw['status'] ?? ''),
2943
+ ...(failure === undefined ? {} : { failure }),
2787
2944
  parts,
2788
2945
  // A circuit-broken part list advertises itself: the caller can see that
2789
2946
  // this message is incomplete and that later parts are still unread, so
@@ -2967,13 +3124,23 @@ class CherryToolEngine {
2967
3124
  * back a snapshot that looks complete while containing content the peer
2968
3125
  * never certified. That is worse than an error: the caller cannot tell it
2969
3126
  * apart from a good read. It keeps failing loudly.
3127
+ * - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
3128
+ * SESSION-wide, not part-specific: the peer refused the arrival, it did not
3129
+ * fail to serve THIS part. Degrading would hand back a "complete" message
3130
+ * whose text is empty for a reason the caller could act on (wait and ask
3131
+ * again) — and if one slice is refused the others will be too, so the
3132
+ * snapshot would be missing precisely what was requested. The connection
3133
+ * already waited out `retryAfterMs` and replayed the slice ONCE, so a
3134
+ * second refusal is a real failure and the whole read fails with it.
2970
3135
  *
2971
3136
  * The discriminator is the DIGEST CHECK specifically, which is why
2972
3137
  * {@link CherryToolEngine.#readExternalized} marks it: matching on message
2973
- * text would break the moment the wording changed.
3138
+ * text would break the moment the wording changed. The rate-limit arm keys on
3139
+ * the peer's REASON, never on wording.
2974
3140
  */
2975
3141
  #degradableReadFailure(error: unknown): boolean {
2976
- return !(error instanceof ContentIntegrityError)
3142
+ if (error instanceof ContentIntegrityError) return false
3143
+ return failureReason(error) !== 'RATE_LIMITED'
2977
3144
  }
2978
3145
 
2979
3146
  /**
@@ -4330,12 +4497,12 @@ class CherryToolEngine {
4330
4497
  * subscription failures still reject this call: the two surfaces stay
4331
4498
  * distinct all the way to the caller (spec §6).
4332
4499
  *
4333
- * ONE exception, and it is a HINT rather than a translation: the peer's
4500
+ * TWO exceptions, and both are HINTS rather than translations: the peer's
4334
4501
  * `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
4335
- * — carries the §6 recovery advice on the rethrown error (see
4336
- * {@link withIdempotencyHint}). The reason, the `data` and the failure
4337
- * currency are untouched; only the prose a caller reads grows the actionable
4338
- * half.
4502
+ * — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
4503
+ * recovery advice on the rethrown error (see {@link withIdempotencyHint} /
4504
+ * {@link withRateLimitHint}). The reason, the `data` and the failure currency
4505
+ * are untouched; only the prose a caller reads grows the actionable half.
4339
4506
  */
4340
4507
  async #command(
4341
4508
  connection: DeviceConnection,
@@ -4345,7 +4512,7 @@ class CherryToolEngine {
4345
4512
  try {
4346
4513
  return (await connection.rpc.request(method, params)) as Record<string, unknown>
4347
4514
  } catch (error) {
4348
- throw withIdempotencyHint(error)
4515
+ throw withRateLimitHint(withIdempotencyHint(error))
4349
4516
  }
4350
4517
  }
4351
4518