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/README.en.md +13 -7
- package/README.md +9 -6
- package/cordis.patch.yml +3 -2
- package/dist/index.d.mts +36 -22
- package/dist/index.mjs +275 -37
- package/docs/e2e-checklist.md +7 -7
- package/package.json +1 -1
- package/src/connection.ts +152 -21
- package/src/defaults.ts +43 -19
- package/src/diagnostics.ts +17 -10
- package/src/index.ts +7 -3
- package/src/tools.ts +176 -9
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* —
|
|
4336
|
-
*
|
|
4337
|
-
*
|
|
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
|
|