dsh-cherry-bridge 0.1.10 → 0.2.0
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 +12 -5
- package/README.md +11 -5
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +1363 -170
- package/docs/e2e-checklist.md +9 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +803 -56
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
package/src/tools.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { truncateErrorMessage } from './diagnostics.js'
|
|
|
16
16
|
import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
|
|
17
17
|
import { PairingError } from './pairing.js'
|
|
18
18
|
import type { PairingErrorKind } from './pairing.js'
|
|
19
|
+
import { failureText } from './text.js'
|
|
19
20
|
import type { AgentCursor } from '@cherrystudio/remote-protocol/agent'
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -291,9 +292,11 @@ export interface CherryReadPart {
|
|
|
291
292
|
toolName?: string
|
|
292
293
|
toolCallId?: string
|
|
293
294
|
/**
|
|
294
|
-
* The DESCRIPTIVE face of
|
|
295
|
-
*
|
|
296
|
-
*
|
|
295
|
+
* The DESCRIPTIVE face of an attachment — a `kind: 'file'` part, or the
|
|
296
|
+
* legacy `kind: 'data'` part the desktop labels `name: 'file'` and whose
|
|
297
|
+
* metadata is JSON in `content.text`: what the file is, never what it holds.
|
|
298
|
+
* `content` stays empty for such a part unless its record carries a key this
|
|
299
|
+
* face does not project — see {@link CherryToolEngine.#attachmentFace}.
|
|
297
300
|
*/
|
|
298
301
|
name?: string
|
|
299
302
|
mediaType?: string
|
|
@@ -604,6 +607,42 @@ export interface CherryCreateResult {
|
|
|
604
607
|
|
|
605
608
|
/** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
|
|
606
609
|
const PAGE_ITEMS_FALLBACK = 100
|
|
610
|
+
/**
|
|
611
|
+
* The page this bridge asks for when the peer's hello advertised
|
|
612
|
+
* `agentMessageSummariesVersion: 1` (issue #105).
|
|
613
|
+
*
|
|
614
|
+
* WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
|
|
615
|
+
* exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
|
|
616
|
+
* schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
|
|
617
|
+
* record cannot hold and the transport kills the connection (`defaults.ts`).
|
|
618
|
+
* What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
|
|
619
|
+
* revision, role, status and optional usage/model/failure, and NO `partIds`.
|
|
620
|
+
*
|
|
621
|
+
* WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
|
|
622
|
+
* the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
|
|
623
|
+
* prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
|
|
624
|
+
* at a halved limit, so its reply records stay under `recordBytes` = 65,536
|
|
625
|
+
* whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
|
|
626
|
+
* shape our walk already tolerates) instead of a killed link. The single-item
|
|
627
|
+
* `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
|
|
628
|
+
* exception arm, and a schema-valid summary row cannot reach it: measured
|
|
629
|
+
* against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
|
|
630
|
+
* 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
|
|
631
|
+
* ≈37 KB — under the 62,279 budget, so the refusal exists for full
|
|
632
|
+
* `partIds`-bearing rows, not summaries.
|
|
633
|
+
*
|
|
634
|
+
* 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
|
|
635
|
+
* all: `#aggregate` still intersects this with the peer's NEGOTIATED
|
|
636
|
+
* `pageItems`, so a peer advertising less keeps its size. Residual exposure —
|
|
637
|
+
* a hypothetical peer advertising the capability WITHOUT a page budget —
|
|
638
|
+
* contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
|
|
639
|
+
* the same "pragmatic, not provable" class `defaults.ts` already scopes.
|
|
640
|
+
*
|
|
641
|
+
* The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
|
|
642
|
+
* in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
|
|
643
|
+
* fattest item there is.
|
|
644
|
+
*/
|
|
645
|
+
const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK
|
|
607
646
|
/** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
|
|
608
647
|
const MAX_AGGREGATE_LIMIT = 1000
|
|
609
648
|
/**
|
|
@@ -749,6 +788,33 @@ interface AggregateWalk<T> {
|
|
|
749
788
|
deadline?: AssemblyDeadline
|
|
750
789
|
}
|
|
751
790
|
|
|
791
|
+
/**
|
|
792
|
+
* The `agent.messages.list` request for ONE message walk, and the page size to
|
|
793
|
+
* ask for — the ONE place the paged-summaries dialect is decided (issue #105).
|
|
794
|
+
*
|
|
795
|
+
* THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
|
|
796
|
+
* `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
|
|
797
|
+
* through 2.1.7 ships — is a STRICT object with no such key, so sending it
|
|
798
|
+
* there is a protocol violation, not a wasted field. Both message-walk faces
|
|
799
|
+
* (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
|
|
800
|
+
* the same reason the resume token has one helper: two call sites spelling the
|
|
801
|
+
* dialect would eventually disagree about which peer may hear it.
|
|
802
|
+
*
|
|
803
|
+
* With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
|
|
804
|
+
* summary rows are metadata, so the byte-safe clamp no longer bounds anything
|
|
805
|
+
* they can carry. Without it, the clamp stays exactly as it was: the
|
|
806
|
+
* count-paged peer still returns `partIds`-bearing rows.
|
|
807
|
+
*/
|
|
808
|
+
function messageListRequest(connection: DeviceConnection): {
|
|
809
|
+
params: Record<string, unknown>
|
|
810
|
+
maxPerPage: number
|
|
811
|
+
} {
|
|
812
|
+
if (connection.limits.messageSummaries === 1) {
|
|
813
|
+
return { params: { partsMode: 'paged' }, maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS }
|
|
814
|
+
}
|
|
815
|
+
return { params: {}, maxPerPage: LIST_PAGE_CLAMP }
|
|
816
|
+
}
|
|
817
|
+
|
|
752
818
|
/** Token prefix for a peer page position; the one `#aggregate` can send back. */
|
|
753
819
|
const RESUME_CURSOR_PREFIX = 'cursor:'
|
|
754
820
|
/** Token prefix for "strictly older than this message id". */
|
|
@@ -862,6 +928,80 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1_048_576
|
|
|
862
928
|
*/
|
|
863
929
|
const MESSAGE_CACHE_BYTES_PER_DEVICE = 33_554_432
|
|
864
930
|
|
|
931
|
+
/**
|
|
932
|
+
* One command the engine issued under a CALLER-SUPPLIED `commandId`, kept so a
|
|
933
|
+
* retry can replay it by receipt (issues #115, #118).
|
|
934
|
+
*
|
|
935
|
+
* The three fields answer the three questions a retry raises:
|
|
936
|
+
*
|
|
937
|
+
* - `method` — is this id being replayed by the tool that minted it? An id
|
|
938
|
+
* remembered for a send says nothing about a respond retry, and the reverse;
|
|
939
|
+
* - `fingerprint` — did the caller ask for the SAME command? The caller-owned
|
|
940
|
+
* arguments only, compared with {@link sameCommandValue}; the DERIVED body
|
|
941
|
+
* fields are deliberately excluded, because they are exactly what moved;
|
|
942
|
+
* - `body` — what the peer's ledger recorded, reproduced verbatim.
|
|
943
|
+
*/
|
|
944
|
+
interface RememberedCommand {
|
|
945
|
+
method: string
|
|
946
|
+
fingerprint: unknown
|
|
947
|
+
body: Record<string, unknown>
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* How many command bodies the engine remembers PER DEVICE, keyed by the
|
|
952
|
+
* CALLER-SUPPLIED `commandId` (issues #115, #118).
|
|
953
|
+
*
|
|
954
|
+
* Why a memory exists at all: a command's recovery path is RECEIPT. A reply
|
|
955
|
+
* lost on the wire is answered by re-sending the ORIGINAL body under the
|
|
956
|
+
* original id, and the desktop's ledger replays the recorded outcome. That
|
|
957
|
+
* promise was impossible for `cherry_send`, whose body carries a DERIVED field
|
|
958
|
+
* (`expectedIdleRevision`) that moves with every successful send: the retry
|
|
959
|
+
* quoted a fresh baseline, the body differed from the recorded one, and the peer
|
|
960
|
+
* answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
|
|
961
|
+
* what already landed. So the engine remembers the EXACT body it emitted, per
|
|
962
|
+
* (device, caller id, method), and a same-id retry replays it verbatim. The
|
|
963
|
+
* same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
|
|
964
|
+
* #118): their DERIVED fields were re-derived, and the retry usually met a
|
|
965
|
+
* pre-check (`unknown-interaction`, `no-active-execution`) before the body was
|
|
966
|
+
* ever compared — the memory is consulted before those, which is why it holds
|
|
967
|
+
* the whole issued body rather than a way to rebuild one.
|
|
968
|
+
*
|
|
969
|
+
* What an entry holds is one short body: a session id, the caller's text (at
|
|
970
|
+
* most the protocol's 32,768 characters, pre-validated before it is ever
|
|
971
|
+
* stored), one revision string — or, for respond/cancel, the interaction's
|
|
972
|
+
* staleness triple / the execution to abort, plus the caller-owned arguments
|
|
973
|
+
* the fingerprint compares. 128 is "more than any live retry window needs,
|
|
974
|
+
* small enough to be trivial": a caller retries a handful of command ids around
|
|
975
|
+
* a lost reply, and each is recoverable while it stays in this FIFO.
|
|
976
|
+
* Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
|
|
977
|
+
* a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
|
|
978
|
+
* reaches the peer, which refuses the differing body exactly as it would
|
|
979
|
+
* without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
|
|
980
|
+
* at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
|
|
981
|
+
* `execution-mismatch`) — the pre-fix failure mode, which names real state.
|
|
982
|
+
*
|
|
983
|
+
* PER DEVICE, like every engine cache: the id is the caller's, but the peer
|
|
984
|
+
* that settled it is not, and a second desktop must never be sent a body minted
|
|
985
|
+
* against the first.
|
|
986
|
+
*
|
|
987
|
+
* INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
|
|
988
|
+
* value needs 129 live command ids before it evicts.
|
|
989
|
+
*/
|
|
990
|
+
const COMMAND_BODY_ENTRIES_PER_DEVICE = 128
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* The three command methods whose bodies the engine remembers (see
|
|
994
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
|
|
995
|
+
*
|
|
996
|
+
* Named once, because they are used twice each by design: as the remembered
|
|
997
|
+
* entry's `method`, and as the method the entry is compared against on lookup.
|
|
998
|
+
* Spelling them at both ends is how a lookup could start matching the wrong
|
|
999
|
+
* tool without any type noticing.
|
|
1000
|
+
*/
|
|
1001
|
+
const SEND_METHOD = 'agent.messages.send'
|
|
1002
|
+
const RESPOND_METHOD = 'agent.interactions.respond'
|
|
1003
|
+
const CANCEL_METHOD = 'agent.executions.cancel'
|
|
1004
|
+
|
|
865
1005
|
/**
|
|
866
1006
|
* EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
|
|
867
1007
|
* legally answers ONE empty page with an advancing cursor (the pin→entity
|
|
@@ -1219,6 +1359,55 @@ function failureReason(error: unknown): string | undefined {
|
|
|
1219
1359
|
return undefined
|
|
1220
1360
|
}
|
|
1221
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* The phrase this hint leaves behind, so applying it twice reads once.
|
|
1364
|
+
*/
|
|
1365
|
+
const PEER_REFUSAL_FINAL_MARK = 'retrying the same request cannot help'
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
|
|
1369
|
+
* carrying `details.retryable === false`, which is the desktop's per-item byte
|
|
1370
|
+
* budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
|
|
1371
|
+
* `itemBytes` / `budgetBytes`), issue #105.
|
|
1372
|
+
*
|
|
1373
|
+
* WHY THE WARNING HAS TO BE ADDED: the official client marks every
|
|
1374
|
+
* `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
|
|
1375
|
+
* exponential backoff, so a byte-budget refusal never converges — and the
|
|
1376
|
+
* condition really is permanent: the same item over the same budget will be
|
|
1377
|
+
* over it again. A caller (or a model reading the error) that retries learns
|
|
1378
|
+
* nothing but spends the peer's rate window.
|
|
1379
|
+
*
|
|
1380
|
+
* The sentence is deliberately narrower than the reason: only the
|
|
1381
|
+
* `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
|
|
1382
|
+
* the upload window, the subscription cap, a legacy refusal with no `details` —
|
|
1383
|
+
* keeps its own text, because some of those ARE worth a later retry.
|
|
1384
|
+
*
|
|
1385
|
+
* Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
|
|
1386
|
+
* structured `data` and the {@link JsonRpcRequestError} identity stay the
|
|
1387
|
+
* peer's, so branching callers see exactly what it sent.
|
|
1388
|
+
*/
|
|
1389
|
+
export function withPeerRefusalHint(error: unknown): unknown {
|
|
1390
|
+
if (!(error instanceof JsonRpcRequestError)) return error
|
|
1391
|
+
if (failureReason(error) !== 'RESOURCE_EXHAUSTED') return error
|
|
1392
|
+
const data = error.data
|
|
1393
|
+
if (!isRecord(data) || !isRecord(data['details']) || data['details']['retryable'] !== false) return error
|
|
1394
|
+
if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error
|
|
1395
|
+
const kind = optionalString(data['details']['kind'])
|
|
1396
|
+
// The two permanent shapes need OPPOSITE advice. ITEM_TOO_LARGE means ONE
|
|
1397
|
+
// item alone exceeds the per-item budget: no page size fits it — that item
|
|
1398
|
+
// and everything behind it are unreachable (the upstream #21951 dead-end),
|
|
1399
|
+
// so suggesting a smaller `limit` would burn round trips learning nothing.
|
|
1400
|
+
const cause = kind === 'ITEM_TOO_LARGE' ? 'an ITEM_TOO_LARGE page item over its byte budget' : 'the same item'
|
|
1401
|
+
const tail =
|
|
1402
|
+
kind === 'ITEM_TOO_LARGE'
|
|
1403
|
+
? 'the item ITSELF exceeds the peer\u2019s per-item budget, so no page size or request shape can fetch it (nor the items behind it — upstream #21951)'
|
|
1404
|
+
: 'ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget'
|
|
1405
|
+
error.message =
|
|
1406
|
+
`${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); ` +
|
|
1407
|
+
`retrying the same request cannot help — ${tail}`
|
|
1408
|
+
return error
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1222
1411
|
/**
|
|
1223
1412
|
* One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
|
|
1224
1413
|
* digest the ref advertises (or there was no digest to check against).
|
|
@@ -1266,6 +1455,89 @@ const IDEMPOTENCY_HINT =
|
|
|
1266
1455
|
'a retry whose parameters changed — never reuse an id with a different body (re-send the ' +
|
|
1267
1456
|
'ORIGINAL body to replay the original receipt).'
|
|
1268
1457
|
|
|
1458
|
+
/**
|
|
1459
|
+
* The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
|
|
1460
|
+
* exactly this), used when the ENGINE is the one that knows the body differs —
|
|
1461
|
+
* a caller-supplied `commandId` it has already emitted with other arguments
|
|
1462
|
+
* (issue #115).
|
|
1463
|
+
*
|
|
1464
|
+
* The refusal is LOCAL because the outcome is already decided: the peer's ledger
|
|
1465
|
+
* holds the recorded body, so a forwarded body would come back as this same
|
|
1466
|
+
* conflict, one round trip later, at the cost of a wire call that can never
|
|
1467
|
+
* succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
|
|
1468
|
+
* (and the existing conflict tests) match on the prefix.
|
|
1469
|
+
*/
|
|
1470
|
+
const COMMAND_BODY_DIFFERS_MESSAGE = 'Command body differs from the recorded command'
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* The JSON-RPC error code the transport reserves for protocol failures (the
|
|
1474
|
+
* server answers every peer-refused request with it). Used for the LOCAL
|
|
1475
|
+
* {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
|
|
1476
|
+
* forwarded one would have been: a `JsonRpcRequestError` carrying
|
|
1477
|
+
* `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
|
|
1478
|
+
* request was ever sent.
|
|
1479
|
+
*
|
|
1480
|
+
* A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
|
|
1481
|
+
* `JsonRpcRequestError` docblock: that module exports no code constant, and
|
|
1482
|
+
* adding one only to name this number would widen the published surface for it.
|
|
1483
|
+
*/
|
|
1484
|
+
const JSON_RPC_PROTOCOL_ERROR_CODE = 1000
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
|
|
1488
|
+
* reason and sentence, wrapped with the same hints a forwarded conflict earns,
|
|
1489
|
+
* and never sent anywhere.
|
|
1490
|
+
*
|
|
1491
|
+
* ONE constructor for every command that remembers its body (send, respond,
|
|
1492
|
+
* cancel): the caller must not have to learn a second vocabulary for one
|
|
1493
|
+
* condition, and a test matches the prefix `Command body differs` whichever
|
|
1494
|
+
* tool produced it.
|
|
1495
|
+
*/
|
|
1496
|
+
function commandBodyDiffers(): Error {
|
|
1497
|
+
const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
|
|
1498
|
+
reason: 'IDEMPOTENCY_CONFLICT',
|
|
1499
|
+
message: COMMAND_BODY_DIFFERS_MESSAGE,
|
|
1500
|
+
})
|
|
1501
|
+
// The two hint helpers pass an error through UNCHANGED (they only append to
|
|
1502
|
+
// a conflict's / a rate-limit's prose), so the identity is the object built
|
|
1503
|
+
// above; the return type keeps the `JsonRpcRequestError` the callers branch
|
|
1504
|
+
// on (the engine's command-failure currency).
|
|
1505
|
+
withRateLimitHint(withIdempotencyHint(conflict))
|
|
1506
|
+
return conflict
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* DEEP structural equality, for the caller-owned half of a remembered command
|
|
1511
|
+
* body (issue #118).
|
|
1512
|
+
*
|
|
1513
|
+
* `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
|
|
1514
|
+
* same answers in a different KEY ORDER are sending the SAME answer, and a
|
|
1515
|
+
* plain `!==` (or a JSON string comparison) would refuse the retry as a
|
|
1516
|
+
* different body — turning the receipt recovery into a local conflict on the
|
|
1517
|
+
* exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
|
|
1518
|
+
* reordering it really is a different value.
|
|
1519
|
+
*
|
|
1520
|
+
* Deliberately small: the values compared here are tool arguments decoded from
|
|
1521
|
+
* JSON, so only JSON's two container kinds, the primitives, and `undefined`
|
|
1522
|
+
* (an omitted optional argument) need to be understood.
|
|
1523
|
+
*/
|
|
1524
|
+
function sameCommandValue(left: unknown, right: unknown): boolean {
|
|
1525
|
+
if (left === right) return true
|
|
1526
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1527
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false
|
|
1528
|
+
if (left.length !== right.length) return false
|
|
1529
|
+
return left.every((value, index) => sameCommandValue(value, right[index]))
|
|
1530
|
+
}
|
|
1531
|
+
if (isRecord(left) && isRecord(right)) {
|
|
1532
|
+
const leftKeys = Object.keys(left)
|
|
1533
|
+
if (leftKeys.length !== Object.keys(right).length) return false
|
|
1534
|
+
return leftKeys.every(
|
|
1535
|
+
(key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]),
|
|
1536
|
+
)
|
|
1537
|
+
}
|
|
1538
|
+
return false
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1269
1541
|
/**
|
|
1270
1542
|
* Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
|
|
1271
1543
|
* gets {@link IDEMPOTENCY_HINT} appended to its message.
|
|
@@ -1478,6 +1750,15 @@ export interface CherryToolEngineOptions {
|
|
|
1478
1750
|
* which no loopback suite should spend its run on.
|
|
1479
1751
|
*/
|
|
1480
1752
|
messageCacheBytesPerDevice?: number
|
|
1753
|
+
/**
|
|
1754
|
+
* Cap on the remembered `cherry_send` bodies for one device — issue #115.
|
|
1755
|
+
* Omitted uses {@link COMMAND_BODY_ENTRIES_PER_DEVICE}.
|
|
1756
|
+
*
|
|
1757
|
+
* Injectable so a test can drive the bound to one and observe the FIFO
|
|
1758
|
+
* arithmetic: at the production 128 a proof needs 129 live command ids, which
|
|
1759
|
+
* no loopback suite should spend its run on.
|
|
1760
|
+
*/
|
|
1761
|
+
commandBodyEntriesPerDevice?: number
|
|
1481
1762
|
/**
|
|
1482
1763
|
* How many `{ref}` contents may be assembled at once across the WHOLE engine
|
|
1483
1764
|
* — every tool call, every device. Omitted uses
|
|
@@ -1488,6 +1769,20 @@ export interface CherryToolEngineOptions {
|
|
|
1488
1769
|
* property under test ("no more than the limit at once") is the same at 1.
|
|
1489
1770
|
*/
|
|
1490
1771
|
contentReadConcurrency?: number
|
|
1772
|
+
/**
|
|
1773
|
+
* The `maxBytes` every `agent.content.read` slice asks for. Omitted uses
|
|
1774
|
+
* {@link CONTENT_READ_MAX_BYTES}, the protocol's `contentBytes`.
|
|
1775
|
+
*
|
|
1776
|
+
* TEST-ONLY, and it exists because the loopback fan-out caps a record at
|
|
1777
|
+
* 32 KiB: at the production 24 KiB no honest ref fits in TWO slices (one
|
|
1778
|
+
* full slice plus the `< 24 KiB` remainder), so a walk's SECOND slice — the
|
|
1779
|
+
* one that proves the ref's digest describes the whole of what is served,
|
|
1780
|
+
* rather than merely its first slice — is unreachable through the fake. A
|
|
1781
|
+
* smaller slice size makes any multi-slice behaviour testable. Never lowered
|
|
1782
|
+
* in production: the peer caps the slice anyway, and asking for less only
|
|
1783
|
+
* costs round trips.
|
|
1784
|
+
*/
|
|
1785
|
+
contentReadSliceBytes?: number
|
|
1491
1786
|
}
|
|
1492
1787
|
|
|
1493
1788
|
/**
|
|
@@ -1536,22 +1831,58 @@ export function registerCherryTools(
|
|
|
1536
1831
|
deps: CherryToolsDeps,
|
|
1537
1832
|
options: CherryToolEngineOptions = {},
|
|
1538
1833
|
): () => void {
|
|
1834
|
+
return registerCherryToolsWithEngine(ctx, deps, options).dispose
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* The same registration, with the ENGINE it built handed back.
|
|
1839
|
+
*
|
|
1840
|
+
* Exists for TESTS: the engine's memory bookkeeping (the remembered command
|
|
1841
|
+
* bodies, the bounds they obey) is private to it on purpose, and the only
|
|
1842
|
+
* honest way to pin "bounded" and "dropped with the device" is to observe it
|
|
1843
|
+
* through this one seam instead of inferring it from retry outcomes that are
|
|
1844
|
+
* identical for an evicted entry and for one that never existed.
|
|
1845
|
+
*
|
|
1846
|
+
* Both this function and `export class CherryToolEngine` are SHIPPED names
|
|
1847
|
+
* (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
|
|
1848
|
+
* compatibility commitment — which is why this seam is kept minimal.
|
|
1849
|
+
*/
|
|
1850
|
+
export function registerCherryToolsWithEngine(
|
|
1851
|
+
ctx: CherryToolsContext,
|
|
1852
|
+
deps: CherryToolsDeps,
|
|
1853
|
+
options: CherryToolEngineOptions = {},
|
|
1854
|
+
): { dispose: () => void; engine: CherryToolEngine | undefined } {
|
|
1539
1855
|
const tools = ctx.tools
|
|
1540
1856
|
if (tools === undefined) {
|
|
1541
1857
|
// Headless deployments have no tool registry. Degrade to a warning rather
|
|
1542
1858
|
// than throwing: the assembly's HTTP/UI half must still come up.
|
|
1543
1859
|
console.warn('[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered')
|
|
1544
|
-
return () => {}
|
|
1860
|
+
return { dispose: () => {}, engine: undefined }
|
|
1545
1861
|
}
|
|
1546
1862
|
|
|
1547
1863
|
const engine = new CherryToolEngine(deps, options)
|
|
1548
1864
|
const disposers = engine.definitions().map((definition) => tools.register(definition))
|
|
1549
|
-
return
|
|
1550
|
-
|
|
1865
|
+
return {
|
|
1866
|
+
engine,
|
|
1867
|
+
dispose: () => {
|
|
1868
|
+
for (const dispose of disposers) dispose()
|
|
1869
|
+
},
|
|
1551
1870
|
}
|
|
1552
1871
|
}
|
|
1553
1872
|
|
|
1554
|
-
/**
|
|
1873
|
+
/**
|
|
1874
|
+
* One tool declaration, in the exact shape the host registry consumes.
|
|
1875
|
+
*
|
|
1876
|
+
* This helper adds no result WRAPPER and this ticket adds no result-side
|
|
1877
|
+
* truncation (issue #161): a tool result is the MODEL's signal, and the peer is
|
|
1878
|
+
* the user's own Noise-authenticated desktop, so shortening the peer's
|
|
1879
|
+
* explanation costs the caller the text it acts on. The two result-text clamps
|
|
1880
|
+
* that DO exist are per-field product choices, not a log rule — `aggregateError`
|
|
1881
|
+
* (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
|
|
1882
|
+
* 200-character diagnostics digest used by `projectMessageFailure`. The LOG
|
|
1883
|
+
* face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
|
|
1884
|
+
* this ticket's sweep does not change tool output.
|
|
1885
|
+
*/
|
|
1555
1886
|
function defineCherryTool(definition: {
|
|
1556
1887
|
name: string
|
|
1557
1888
|
description: string
|
|
@@ -1574,7 +1905,7 @@ function defineCherryTool(definition: {
|
|
|
1574
1905
|
* share. Kept as one class (rather than per-face closures) so the per-session
|
|
1575
1906
|
* and per-device state the later faces add has an obvious home.
|
|
1576
1907
|
*/
|
|
1577
|
-
class CherryToolEngine {
|
|
1908
|
+
export class CherryToolEngine {
|
|
1578
1909
|
readonly #deps: CherryToolsDeps
|
|
1579
1910
|
/**
|
|
1580
1911
|
* Circuit-breaker for the parts walk. A LOCAL policy value, injectable so a
|
|
@@ -1649,6 +1980,12 @@ class CherryToolEngine {
|
|
|
1649
1980
|
* being read, shared by every tool call and every device this engine serves.
|
|
1650
1981
|
*/
|
|
1651
1982
|
readonly #contentGate: ContentReadGate
|
|
1983
|
+
/**
|
|
1984
|
+
* The `maxBytes` one `agent.content.read` slice asks for
|
|
1985
|
+
* ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
|
|
1986
|
+
* reach a walk's second slice, see `CherryToolEngineOptions`).
|
|
1987
|
+
*/
|
|
1988
|
+
readonly #contentReadSliceBytes: number
|
|
1652
1989
|
/**
|
|
1653
1990
|
* The parts pages already walked, keyed `<deviceId>\0<messageId>`.
|
|
1654
1991
|
*
|
|
@@ -1719,7 +2056,49 @@ class CherryToolEngine {
|
|
|
1719
2056
|
* not pinned by bookkeeping.
|
|
1720
2057
|
*/
|
|
1721
2058
|
readonly #watchedConnections = new WeakSet<DeviceConnection>()
|
|
1722
|
-
|
|
2059
|
+
/**
|
|
2060
|
+
* The EXACT command body emitted for each (device, caller id, method), so a
|
|
2061
|
+
* lost reply can be recovered by receipt (issues #115, #118).
|
|
2062
|
+
*
|
|
2063
|
+
* Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
|
|
2064
|
+
* value is a {@link RememberedCommand}: the METHOD it was minted for, the
|
|
2065
|
+
* caller-owned FINGERPRINT a retry must match, and the BODY the retry
|
|
2066
|
+
* replays verbatim. The fingerprint is what must MATCH — a same-id call whose
|
|
2067
|
+
* arguments differ is not a retry of this command at all, and is refused
|
|
2068
|
+
* locally rather than sent. The body is what must be REPLAYED, derived
|
|
2069
|
+
* fields included: that is the whole point, because those fields are the ones
|
|
2070
|
+
* a retry cannot re-derive the same way.
|
|
2071
|
+
*
|
|
2072
|
+
* Three tools remember: `agent.messages.send` (the baseline it quoted),
|
|
2073
|
+
* `agent.interactions.respond` (the staleness triple) and
|
|
2074
|
+
* `agent.executions.cancel` (the execution it was told to abort). Two of
|
|
2075
|
+
* them run PRE-CHECKS before composing a body — respond's
|
|
2076
|
+
* `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
|
|
2077
|
+
* and after a lost reply those pre-checks are usually the first thing the
|
|
2078
|
+
* retry meets, because the first attempt is what SETTLED the interaction and
|
|
2079
|
+
* ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
|
|
2080
|
+
* a remembered retry is an answer, not a fresh call.
|
|
2081
|
+
*
|
|
2082
|
+
* WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
|
|
2083
|
+
* one-shot identity — nobody can retry it, so an entry for it is pure leak.
|
|
2084
|
+
* The entry is written the moment the command is ISSUED, before the reply is
|
|
2085
|
+
* awaited: a reply lost on the wire must not cost the only copy of the bytes
|
|
2086
|
+
* the peer's ledger will accept.
|
|
2087
|
+
*
|
|
2088
|
+
* Which body an id holds: the LATEST attempt issued for it — the first
|
|
2089
|
+
* attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
|
|
2090
|
+
* same-id retry is therefore answered with the peer's conflict, deliberately:
|
|
2091
|
+
* replaying the ledger's rejected receipt would re-run the healed admission
|
|
2092
|
+
* and duplicate the run, and a conflict is the safer failure.
|
|
2093
|
+
*
|
|
2094
|
+
* Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
|
|
2095
|
+
* identity-mismatch) but KEPT across an ordinary reconnect — see
|
|
2096
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
|
|
2097
|
+
* {@link CherryToolEngine.#forgetDevice}.
|
|
2098
|
+
*/
|
|
2099
|
+
readonly #commandBodies = new Map<string, RememberedCommand>()
|
|
2100
|
+
/** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
|
|
2101
|
+
readonly #commandBodyEntriesPerDevice: number
|
|
1723
2102
|
constructor(deps: CherryToolsDeps, options: CherryToolEngineOptions = {}) {
|
|
1724
2103
|
this.#deps = deps
|
|
1725
2104
|
this.#partsSafetyBound = Math.max(
|
|
@@ -1743,7 +2122,15 @@ class CherryToolEngine {
|
|
|
1743
2122
|
1,
|
|
1744
2123
|
Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE),
|
|
1745
2124
|
)
|
|
2125
|
+
this.#commandBodyEntriesPerDevice = Math.max(
|
|
2126
|
+
1,
|
|
2127
|
+
Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE),
|
|
2128
|
+
)
|
|
1746
2129
|
this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY)
|
|
2130
|
+
this.#contentReadSliceBytes = Math.max(
|
|
2131
|
+
1,
|
|
2132
|
+
Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES),
|
|
2133
|
+
)
|
|
1747
2134
|
}
|
|
1748
2135
|
|
|
1749
2136
|
definitions(): CherryToolDefinition[] {
|
|
@@ -2137,7 +2524,12 @@ class CherryToolEngine {
|
|
|
2137
2524
|
if (deadline !== undefined && deadlineExhaustion(error)) {
|
|
2138
2525
|
return this.#truncated(items, 'deadline', cursor)
|
|
2139
2526
|
}
|
|
2140
|
-
|
|
2527
|
+
// A PERMANENT peer refusal (issue #105) grows its advice HERE, at the
|
|
2528
|
+
// one seam every paged walk's refusals pass through, and ONLY the
|
|
2529
|
+
// `details.retryable: false` shape: a walk has no retry of its own to
|
|
2530
|
+
// reason about, and the caller is the party that must change the
|
|
2531
|
+
// request. The error object, its reason and its `data` are unchanged.
|
|
2532
|
+
throw withPeerRefusalHint(error)
|
|
2141
2533
|
}
|
|
2142
2534
|
const extracted = extract(result)
|
|
2143
2535
|
// THE PAGE'S OWN SHAPE (issue #46), judged on EVERY page — terminal or
|
|
@@ -2596,8 +2988,11 @@ class CherryToolEngine {
|
|
|
2596
2988
|
'sha256-verified through `agent.content.read`. An unchanged message (same message ' +
|
|
2597
2989
|
'revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a ' +
|
|
2598
2990
|
'quiet session costs no `agent.parts.list` RPC; externalized content is still fetched ' +
|
|
2599
|
-
'and sha256-verified on every read, so unverified bytes are never served from memory.
|
|
2600
|
-
'
|
|
2991
|
+
'and sha256-verified on every read, so unverified bytes are never served from memory. An ' +
|
|
2992
|
+
'attachment is reported as METADATA, never as its bytes: a `kind: "file"` part, or a ' +
|
|
2993
|
+
'legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as ' +
|
|
2994
|
+
'`name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or ' +
|
|
2995
|
+
'kept verbatim when that record carries a key this projection does not report. The bytes ' +
|
|
2601
2996
|
'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
|
|
2602
2997
|
'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
|
|
2603
2998
|
'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
|
|
@@ -2753,15 +3148,18 @@ class CherryToolEngine {
|
|
|
2753
3148
|
throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`)
|
|
2754
3149
|
}
|
|
2755
3150
|
|
|
3151
|
+
// The paged-summaries dialect (issue #105) from the ONE helper both message
|
|
3152
|
+
// faces share: `partsMode` only for a peer whose hello advertised it.
|
|
3153
|
+
const messageList = messageListRequest(connection)
|
|
2756
3154
|
const page = await this.#aggregate(
|
|
2757
3155
|
connection,
|
|
2758
3156
|
'agent.messages.list',
|
|
2759
|
-
{ sessionId, historyRevision },
|
|
3157
|
+
{ sessionId, historyRevision, ...messageList.params },
|
|
2760
3158
|
(result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
|
|
2761
3159
|
{
|
|
2762
3160
|
limit,
|
|
2763
3161
|
safetyBound: MAX_AGGREGATE_LIMIT,
|
|
2764
|
-
maxPerPage:
|
|
3162
|
+
maxPerPage: messageList.maxPerPage,
|
|
2765
3163
|
resume:
|
|
2766
3164
|
resume === undefined
|
|
2767
3165
|
? undefined
|
|
@@ -3053,52 +3451,152 @@ class CherryToolEngine {
|
|
|
3053
3451
|
text = ''
|
|
3054
3452
|
}
|
|
3055
3453
|
|
|
3454
|
+
// A LEGACY ATTACHMENT is a `data` part whose metadata is JSON TEXT, not an
|
|
3455
|
+
// object (see {@link CherryToolEngine.#attachmentFace}). The face is lifted
|
|
3456
|
+
// here, from the ALREADY-RESOLVED text — the same text a `{ref}` payload
|
|
3457
|
+
// resolves to — so an externalized metadata record lifts identically.
|
|
3458
|
+
const legacy = this.#legacyAttachment(kind, raw, text)
|
|
3056
3459
|
return {
|
|
3057
3460
|
partId,
|
|
3058
3461
|
kind,
|
|
3059
3462
|
revision,
|
|
3060
|
-
|
|
3061
|
-
//
|
|
3062
|
-
//
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3463
|
+
// THE CONTENT RULE: the JSON text is emptied only when EVERY key of the
|
|
3464
|
+
// record is one the face projects; a record carrying keys we do not
|
|
3465
|
+
// project keeps its raw text, so nothing is silently dropped.
|
|
3466
|
+
content: legacy === undefined ? text : legacy.remainder,
|
|
3467
|
+
...(legacy === undefined
|
|
3468
|
+
? this.#attachmentFace(kind, raw, content)
|
|
3469
|
+
: this.#attachmentFace(kind, raw, content, legacy.record)),
|
|
3066
3470
|
...(typeof raw['state'] === 'string' ? { state: raw['state'] } : {}),
|
|
3067
3471
|
...(typeof raw['toolName'] === 'string' ? { toolName: raw['toolName'] } : {}),
|
|
3068
3472
|
...(typeof raw['toolCallId'] === 'string' ? { toolCallId: raw['toolCallId'] } : {}),
|
|
3069
3473
|
}
|
|
3070
3474
|
}
|
|
3071
3475
|
|
|
3476
|
+
/**
|
|
3477
|
+
* The ONE place a legacy `data` part is recognised as an attachment: the
|
|
3478
|
+
* structural discriminator, the JSON parse, and the lossless CONTENT rule.
|
|
3479
|
+
*
|
|
3480
|
+
* The legal legacy face is `{kind: 'data', name: 'file', content: {text:
|
|
3481
|
+
* '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
|
|
3482
|
+
* `content.text`, observed on the wire (2.1.7 desktop, image upload,
|
|
3483
|
+
* 2026-10-22). An OBJECT inside `content` is not producible by a conforming
|
|
3484
|
+
* peer at all: the pinned protocol 0.7.0 types `content` as a strict
|
|
3485
|
+
* `{text}` | `{ref}` union, so the old documentation described a shape no
|
|
3486
|
+
* peer can emit — and the real one was never lifted, which is how an image
|
|
3487
|
+
* upload reached a model as a JSON blob.
|
|
3488
|
+
*
|
|
3489
|
+
* WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
|
|
3490
|
+
* desktop labels every `data` part with its data KIND in `name` — an
|
|
3491
|
+
* attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
|
|
3492
|
+
* error is `'data-error'` — and the protocol types that field as an
|
|
3493
|
+
* `opaqueId`. This lift therefore REQUIRES the label to be the literal
|
|
3494
|
+
* `'file'` rather than ASSUMING every peer sets one: a `data` part that
|
|
3495
|
+
* carries no name reads as no name, is not lifted, and passes through
|
|
3496
|
+
* untouched. Several of those payloads are JSON too, so "the text parses"
|
|
3497
|
+
* proves nothing; `'file'` is the one label that means "this is an
|
|
3498
|
+
* attachment".
|
|
3499
|
+
*
|
|
3500
|
+
* NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
|
|
3501
|
+
* non-record, or carries neither `mediaType` nor `filename` as a string is
|
|
3502
|
+
* not an attachment, and the part passes through COMPLETELY unchanged
|
|
3503
|
+
* (`remainder` is the raw text, no field is lifted) — this runs on every
|
|
3504
|
+
* `data` part of every read, so a throw here would take down the whole
|
|
3505
|
+
* snapshot.
|
|
3506
|
+
*
|
|
3507
|
+
* THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
|
|
3508
|
+
* this face projects (`mediaType`, `filename`) — the metadata is fully
|
|
3509
|
+
* represented, and a model sees structure rather than a blob. A record
|
|
3510
|
+
* carrying ANY other key keeps its raw text, so a field we do not project is
|
|
3511
|
+
* never silently dropped (lossless by default). That promise is about KEYS: a
|
|
3512
|
+
* key we DO project whose value is not a string (say `mediaType: 123`) is
|
|
3513
|
+
* counted as represented — the key survives — and reports no field, because
|
|
3514
|
+
* this face lifts strings only. Emptied text therefore means "every key was
|
|
3515
|
+
* one of ours", not "every value produced a field".
|
|
3516
|
+
*/
|
|
3517
|
+
#legacyAttachment(
|
|
3518
|
+
kind: string,
|
|
3519
|
+
raw: Record<string, unknown>,
|
|
3520
|
+
text: string,
|
|
3521
|
+
): { record: Record<string, unknown>; remainder: string } | undefined {
|
|
3522
|
+
if (kind !== 'data' || optionalString(raw['name']) !== 'file') return undefined
|
|
3523
|
+
let parsed: unknown
|
|
3524
|
+
try {
|
|
3525
|
+
parsed = JSON.parse(text) as unknown
|
|
3526
|
+
} catch {
|
|
3527
|
+
return undefined
|
|
3528
|
+
}
|
|
3529
|
+
if (!isRecord(parsed)) return undefined
|
|
3530
|
+
// At least ONE projected key must be a string: a record naming neither
|
|
3531
|
+
// `mediaType` nor `filename` says nothing about an attachment, whatever
|
|
3532
|
+
// its name label claims.
|
|
3533
|
+
if (optionalString(parsed['filename']) === undefined && optionalString(parsed['mediaType']) === undefined) {
|
|
3534
|
+
return undefined
|
|
3535
|
+
}
|
|
3536
|
+
const everythingLifted = Object.keys(parsed).every((key) => key === 'mediaType' || key === 'filename')
|
|
3537
|
+
return { record: parsed, remainder: everythingLifted ? '' : text }
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3072
3540
|
/**
|
|
3073
3541
|
* The descriptive metadata of an attachment part — and deliberately NOT a
|
|
3074
3542
|
* way to reach its bytes.
|
|
3075
3543
|
*
|
|
3076
|
-
* Two
|
|
3077
|
-
*
|
|
3544
|
+
* Two legal faces are covered, because both are inside the verified range and
|
|
3545
|
+
* they disagree about where the metadata lives:
|
|
3078
3546
|
*
|
|
3079
3547
|
* - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
|
|
3080
|
-
* - **
|
|
3548
|
+
* - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
|
|
3549
|
+
* the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
|
|
3550
|
+
* parses it and passes the record in here.
|
|
3551
|
+
*
|
|
3552
|
+
* A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
|
|
3553
|
+
* inside `content` — is schema-ILLEGAL under the pinned protocol (see the
|
|
3554
|
+
* docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
|
|
3555
|
+
* It is not the 2.1.4 projection: that story was wrong, and a misbehaving
|
|
3556
|
+
* peer's parts are not re-validated client-side, so the guarded arm costs
|
|
3557
|
+
* nothing and covers exactly the peer it was written for.
|
|
3081
3558
|
*
|
|
3082
3559
|
* Only string fields are lifted: a null `filename` (the peer's explicit "no
|
|
3083
3560
|
* name") stays absent rather than becoming the string "null". Anything a
|
|
3084
3561
|
* caller needs beyond this — the bytes — stays behind `agent.content.read`,
|
|
3085
3562
|
* which this plugin does not call for a file part.
|
|
3563
|
+
*
|
|
3564
|
+
* WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
|
|
3565
|
+
* attachment's record can arrive externalized as `{ref}`, and that ref is the
|
|
3566
|
+
* peer's serialization ENVELOPE, not the attachment's description: its
|
|
3567
|
+
* `mediaType` is the literal `'text/plain'` the envelope is labelled with and
|
|
3568
|
+
* its `byteLength` is the JSON metadata's own byte count. Letting either
|
|
3569
|
+
* outrank the record would report `text/plain` as an image's media type and a
|
|
3570
|
+
* size that is not the file's — so the record wins the media type, and this
|
|
3571
|
+
* face reports NO `byteLength` at all (a legacy `data` part carries none). The
|
|
3572
|
+
* `file` arm is untouched: there the ref IS the file's own description.
|
|
3086
3573
|
*/
|
|
3087
3574
|
#attachmentFace(
|
|
3088
3575
|
kind: string,
|
|
3089
3576
|
raw: Record<string, unknown>,
|
|
3090
3577
|
content: Record<string, unknown>,
|
|
3578
|
+
metadata?: Record<string, unknown>,
|
|
3091
3579
|
): { name?: string; mediaType?: string; byteLength?: string } {
|
|
3092
3580
|
if (kind !== 'file' && kind !== 'data') return {}
|
|
3093
3581
|
const ref = isRecord(raw['ref']) ? raw['ref'] : isRecord(content['ref']) ? content['ref'] : {}
|
|
3094
3582
|
// WHERE the name lives is part of the shape, not a matter of preference: on
|
|
3095
3583
|
// a `file` part `name` IS the filename, while a `data` part's `name` is the
|
|
3096
|
-
// data KIND label
|
|
3097
|
-
//
|
|
3098
|
-
// the data kind would report "file" as the attachment's name.
|
|
3099
|
-
const name =
|
|
3100
|
-
|
|
3101
|
-
|
|
3584
|
+
// data KIND label (the attachment's is the literal `'file'`), so a legacy
|
|
3585
|
+
// filename can only come from the metadata record's `filename`. Reading
|
|
3586
|
+
// `name` for the data kind would report "file" as the attachment's name.
|
|
3587
|
+
const name =
|
|
3588
|
+
kind === 'file'
|
|
3589
|
+
? optionalString(raw['name'])
|
|
3590
|
+
: (optionalString(metadata?.['filename']) ?? optionalString(content['filename']))
|
|
3591
|
+
const mediaType =
|
|
3592
|
+
kind === 'data' && metadata !== undefined
|
|
3593
|
+
? (optionalString(metadata['mediaType']) ?? optionalString(content['mediaType']))
|
|
3594
|
+
: (optionalString(ref['mediaType']) ??
|
|
3595
|
+
optionalString(metadata?.['mediaType']) ??
|
|
3596
|
+
optionalString(content['mediaType']))
|
|
3597
|
+
// A legacy `data` part carries no `byteLength`: the only ref it can have is
|
|
3598
|
+
// the metadata envelope's, whose length describes the RECORD.
|
|
3599
|
+
const byteLength = kind === 'file' ? optionalString(ref['byteLength']) : undefined
|
|
3102
3600
|
return {
|
|
3103
3601
|
...(name === undefined ? {} : { name }),
|
|
3104
3602
|
...(mediaType === undefined ? {} : { mediaType }),
|
|
@@ -3209,7 +3707,7 @@ class CherryToolEngine {
|
|
|
3209
3707
|
const refusal = this.#externalizedRefusal(ref)
|
|
3210
3708
|
if (refusal !== undefined) throw refusal
|
|
3211
3709
|
const maxSlices = Number.isSafeInteger(declaredBytes)
|
|
3212
|
-
? Math.max(1, Math.ceil(declaredBytes /
|
|
3710
|
+
? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes))
|
|
3213
3711
|
: DEFAULT_CONTENT_SLICE_BOUND
|
|
3214
3712
|
|
|
3215
3713
|
let offset = '0'
|
|
@@ -3238,7 +3736,9 @@ class CherryToolEngine {
|
|
|
3238
3736
|
contentId,
|
|
3239
3737
|
revision,
|
|
3240
3738
|
offset,
|
|
3241
|
-
|
|
3739
|
+
// The protocol's slice size in production; injectable for tests that
|
|
3740
|
+
// must reach a walk's SECOND slice (see the option's docblock).
|
|
3741
|
+
maxBytes: this.#contentReadSliceBytes,
|
|
3242
3742
|
},
|
|
3243
3743
|
deadline === undefined ? undefined : { deadline: deadline.instant },
|
|
3244
3744
|
)) as Record<string, unknown>
|
|
@@ -3458,7 +3958,16 @@ class CherryToolEngine {
|
|
|
3458
3958
|
try {
|
|
3459
3959
|
await subscriptions.ack(sessionId, result.nextCursor)
|
|
3460
3960
|
} catch (error) {
|
|
3461
|
-
|
|
3961
|
+
// ONE bounded STRING (issue #161): the ack refusal is the PEER's
|
|
3962
|
+
// JSON-RPC `error.message` verbatim (a CONFLICT from an out-of-window
|
|
3963
|
+
// cursor, a reset race), so the old object print let the console
|
|
3964
|
+
// render peer text with no local bound. Ceiling:
|
|
3965
|
+
// `[cherry-remote] acknowledging session ` (38) + sessionId +
|
|
3966
|
+
// ` at ` (4) + seq + ` failed: ` (9) + LOG_TEXT_MAX (240).
|
|
3967
|
+
console.warn(
|
|
3968
|
+
`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` +
|
|
3969
|
+
failureText(error),
|
|
3970
|
+
)
|
|
3462
3971
|
this.#reEnsureInBackground(record.deviceId, sessionId)
|
|
3463
3972
|
}
|
|
3464
3973
|
return { events: result.events, nextCursor: result.nextCursor }
|
|
@@ -3774,12 +4283,37 @@ class CherryToolEngine {
|
|
|
3774
4283
|
request: { args: Record<string, unknown>; text: string },
|
|
3775
4284
|
): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> {
|
|
3776
4285
|
const { args, text } = request
|
|
4286
|
+
const callerId = optionalString(args['commandId'])
|
|
3777
4287
|
const attempt = async (
|
|
3778
4288
|
commandId: string,
|
|
3779
4289
|
revisionOverride?: string,
|
|
3780
4290
|
): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> => {
|
|
4291
|
+
// The memory belongs to the id it was MINTED for, so the lookup is gated
|
|
4292
|
+
// on `commandId === callerId`: the self-heal's fresh-uuid attempt derives
|
|
4293
|
+
// its own baseline instead of inheriting the caller's.
|
|
4294
|
+
const remembered =
|
|
4295
|
+
callerId === undefined || commandId !== callerId
|
|
4296
|
+
? undefined
|
|
4297
|
+
: this.#recallCommandBody(deviceId, callerId)
|
|
4298
|
+
// A remembered id that belongs to ANOTHER method is a mismatch, not a
|
|
4299
|
+
// miss: refusing locally keeps the decision off the wire.
|
|
4300
|
+
if (remembered !== undefined && remembered.method !== SEND_METHOD) throw commandBodyDiffers()
|
|
4301
|
+
// A same-id call whose caller-owned arguments differ from the remembered
|
|
4302
|
+
// body is not a retry of that command: the peer holds the original body,
|
|
4303
|
+
// so the conflict is already decided and is answered LOCALLY, in the
|
|
4304
|
+
// peer's own words, with no wire call.
|
|
4305
|
+
const rememberedBody = remembered?.body
|
|
4306
|
+
const mismatched =
|
|
4307
|
+
remembered !== undefined &&
|
|
4308
|
+
(rememberedBody?.['sessionId'] !== sessionId || rememberedBody['text'] !== text)
|
|
4309
|
+
if (mismatched) throw commandBodyDiffers()
|
|
4310
|
+
// THE REMEMBERED BODY IS REPLAYED, derived field included. Rebuilding it
|
|
4311
|
+
// from a derived `expectedIdleRevision` instead is what #115's memory
|
|
4312
|
+
// exists to stop, so the value is read back rather than recomputed.
|
|
3781
4313
|
const expectedIdleRevision =
|
|
3782
|
-
|
|
4314
|
+
optionalString(rememberedBody?.['expectedIdleRevision']) ??
|
|
4315
|
+
revisionOverride ??
|
|
4316
|
+
(await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
|
|
3783
4317
|
// THE BASELINE IS TAKEN BEFORE THE COMMAND IS ISSUED, and that ordering
|
|
3784
4318
|
// is what makes a fast run observable at all. The waiter's baseline is
|
|
3785
4319
|
// the cut that decides which events belong to THIS run; taking it after
|
|
@@ -3791,7 +4325,16 @@ class CherryToolEngine {
|
|
|
3791
4325
|
// Nothing before this point can emit this run's events: the command has
|
|
3792
4326
|
// not been sent yet.
|
|
3793
4327
|
const baselineSeq = this.#newestSeq(subscriptions, sessionId)
|
|
3794
|
-
|
|
4328
|
+
// The body is remembered the moment it is ISSUED — a reply lost on the wire
|
|
4329
|
+
// must not cost the only copy of the bytes the peer's ledger will accept.
|
|
4330
|
+
if (callerId !== undefined) {
|
|
4331
|
+
this.#rememberCommandBody(deviceId, callerId, {
|
|
4332
|
+
method: SEND_METHOD,
|
|
4333
|
+
fingerprint: { sessionId, text },
|
|
4334
|
+
body: { commandId, sessionId, text, expectedIdleRevision },
|
|
4335
|
+
})
|
|
4336
|
+
}
|
|
4337
|
+
const receipt = await this.#command(connection, SEND_METHOD, {
|
|
3795
4338
|
commandId,
|
|
3796
4339
|
sessionId,
|
|
3797
4340
|
text,
|
|
@@ -4187,6 +4730,26 @@ class CherryToolEngine {
|
|
|
4187
4730
|
}
|
|
4188
4731
|
}
|
|
4189
4732
|
|
|
4733
|
+
const callerId = optionalString(args['commandId'])
|
|
4734
|
+
const answer: Record<string, unknown> = response === undefined ? { decision } : { response }
|
|
4735
|
+
// A RETRY IS ANSWERED BEFORE ANYTHING IS DERIVED. The staleness triple
|
|
4736
|
+
// below is derived from what the plugin has observed, and after a lost
|
|
4737
|
+
// reply that observation has MOVED (the first answer settled the card)
|
|
4738
|
+
// or vanished (the peer no longer serves it). Re-deriving would either
|
|
4739
|
+
// quote a body the ledger never recorded or — the usual case — stop at
|
|
4740
|
+
// the `unknown-interaction` verdict below, before the body is ever
|
|
4741
|
+
// compared. So a caller-supplied id is consulted FIRST: same id, same
|
|
4742
|
+
// caller-owned arguments and same method replays the remembered body
|
|
4743
|
+
// verbatim, and the snapshot is not asked at all.
|
|
4744
|
+
const remembered =
|
|
4745
|
+
callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
|
|
4746
|
+
if (remembered !== undefined && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers()
|
|
4747
|
+
if (remembered !== undefined) {
|
|
4748
|
+
const fingerprint = { sessionId, interactionId, ...answer }
|
|
4749
|
+
if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers()
|
|
4750
|
+
return this.#command(connection, RESPOND_METHOD, remembered.body)
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4190
4753
|
await this.#ensureSubscription(record.deviceId, sessionId)
|
|
4191
4754
|
const subscriptions = this.#deps.subs(record.deviceId)
|
|
4192
4755
|
|
|
@@ -4209,15 +4772,27 @@ class CherryToolEngine {
|
|
|
4209
4772
|
} satisfies Record<string, unknown>
|
|
4210
4773
|
}
|
|
4211
4774
|
|
|
4212
|
-
|
|
4213
|
-
|
|
4775
|
+
const commandId = this.#commandId(args)
|
|
4776
|
+
const body = {
|
|
4777
|
+
commandId,
|
|
4214
4778
|
sessionId,
|
|
4215
4779
|
interactionId,
|
|
4216
4780
|
expectedRevision: snapshot.revision,
|
|
4217
4781
|
expectedExecutionId: snapshot.executionId,
|
|
4218
4782
|
inputDigest: snapshot.inputDigest,
|
|
4219
|
-
...
|
|
4220
|
-
}
|
|
4783
|
+
...answer,
|
|
4784
|
+
}
|
|
4785
|
+
// ISSUE-TIME WRITE: the body is fully composed here, so it is remembered
|
|
4786
|
+
// before the reply is awaited — a reply lost on the wire must not cost
|
|
4787
|
+
// the only copy of the bytes the peer's ledger will accept.
|
|
4788
|
+
if (callerId !== undefined) {
|
|
4789
|
+
this.#rememberCommandBody(record.deviceId, callerId, {
|
|
4790
|
+
method: RESPOND_METHOD,
|
|
4791
|
+
fingerprint: { sessionId, interactionId, ...answer },
|
|
4792
|
+
body,
|
|
4793
|
+
})
|
|
4794
|
+
}
|
|
4795
|
+
return this.#command(connection, RESPOND_METHOD, body)
|
|
4221
4796
|
},
|
|
4222
4797
|
})
|
|
4223
4798
|
}
|
|
@@ -4259,6 +4834,27 @@ class CherryToolEngine {
|
|
|
4259
4834
|
if (resolved.link === undefined) return resolved.verdict
|
|
4260
4835
|
const { connection, record } = resolved.link
|
|
4261
4836
|
const sessionId = requiredString(args, 'sessionId')
|
|
4837
|
+
const callerId = optionalString(args['commandId'])
|
|
4838
|
+
// The caller's own value is a PRE-CHECK, not a body field — but it is
|
|
4839
|
+
// part of what the caller ASKED FOR, so it belongs in the fingerprint: a
|
|
4840
|
+
// retry asserting a different execution is not a retry of this cancel.
|
|
4841
|
+
const supplied = optionalString(args['expectedExecutionId'])
|
|
4842
|
+
|
|
4843
|
+
// A RETRY IS ANSWERED BEFORE THE PRE-CHECKS — the same ordering (and
|
|
4844
|
+
// the same reason) as `agent.interactions.respond`'s consult above;
|
|
4845
|
+
// what differs here is only which verdicts would otherwise eat the
|
|
4846
|
+
// retry: `no-active-execution` and `execution-mismatch` describe the
|
|
4847
|
+
// state RIGHT NOW, and a lost reply left the run the first attempt
|
|
4848
|
+
// cancelled already finished.
|
|
4849
|
+
const remembered =
|
|
4850
|
+
callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
|
|
4851
|
+
if (remembered !== undefined && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers()
|
|
4852
|
+
if (remembered !== undefined) {
|
|
4853
|
+
if (!sameCommandValue(remembered.fingerprint, { sessionId, expectedExecutionId: supplied })) {
|
|
4854
|
+
throw commandBodyDiffers()
|
|
4855
|
+
}
|
|
4856
|
+
return this.#command(connection, CANCEL_METHOD, remembered.body)
|
|
4857
|
+
}
|
|
4262
4858
|
|
|
4263
4859
|
await this.#ensureSubscription(record.deviceId, sessionId)
|
|
4264
4860
|
const subscriptions = this.#deps.subs(record.deviceId)
|
|
@@ -4274,7 +4870,6 @@ class CherryToolEngine {
|
|
|
4274
4870
|
message: `session ${sessionId} has no in-flight execution to cancel`,
|
|
4275
4871
|
} satisfies Record<string, unknown>
|
|
4276
4872
|
}
|
|
4277
|
-
const supplied = optionalString(args['expectedExecutionId'])
|
|
4278
4873
|
if (supplied !== undefined && supplied !== expectedExecutionId) {
|
|
4279
4874
|
return {
|
|
4280
4875
|
error: 'execution-mismatch',
|
|
@@ -4286,11 +4881,18 @@ class CherryToolEngine {
|
|
|
4286
4881
|
} satisfies Record<string, unknown>
|
|
4287
4882
|
}
|
|
4288
4883
|
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4884
|
+
const commandId = this.#commandId(args)
|
|
4885
|
+
const body = { commandId, sessionId, expectedExecutionId }
|
|
4886
|
+
// ISSUE-TIME WRITE, as respond's: the derived execution id is exactly
|
|
4887
|
+
// the field a retry cannot re-derive once the run has ended.
|
|
4888
|
+
if (callerId !== undefined) {
|
|
4889
|
+
this.#rememberCommandBody(record.deviceId, callerId, {
|
|
4890
|
+
method: CANCEL_METHOD,
|
|
4891
|
+
fingerprint: { sessionId, expectedExecutionId: supplied },
|
|
4892
|
+
body,
|
|
4893
|
+
})
|
|
4894
|
+
}
|
|
4895
|
+
return this.#command(connection, CANCEL_METHOD, body)
|
|
4294
4896
|
},
|
|
4295
4897
|
})
|
|
4296
4898
|
}
|
|
@@ -4451,7 +5053,7 @@ class CherryToolEngine {
|
|
|
4451
5053
|
// pairing's idle revisions and execution ids, and would grow the maps
|
|
4452
5054
|
// for the lifetime of the process. The store record is untouched by
|
|
4453
5055
|
// this — the caches are pure derived state and can always be rebuilt.
|
|
4454
|
-
this.#forgetDevice(record.deviceId)
|
|
5056
|
+
this.#forgetDevice(record.deviceId, { dropCommandBodies: true })
|
|
4455
5057
|
await this.#deps.evict(record.deviceId)
|
|
4456
5058
|
// The remove is GUARDED, not merely ordered. `#selectRecords` already
|
|
4457
5059
|
// rejects a quarantined store before a record can be named, so reaching
|
|
@@ -4812,7 +5414,8 @@ class CherryToolEngine {
|
|
|
4812
5414
|
* the stored credential no longer describes a valid pairing. A transient
|
|
4813
5415
|
* network drop ALSO lands here and clears the caches unnecessarily; that
|
|
4814
5416
|
* is accepted, because the cost is one round trip per session on reuse and
|
|
4815
|
-
* the alternative is keeping snapshots across an identity change.
|
|
5417
|
+
* the alternative is keeping snapshots across an identity change. (The
|
|
5418
|
+
* remembered command BODIES are the one exception: see `#forgetDevice`.)
|
|
4816
5419
|
* - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
|
|
4817
5420
|
*
|
|
4818
5421
|
* {@link CherryToolEngine.#resolve} installs the watch on every tool call that
|
|
@@ -4840,7 +5443,8 @@ class CherryToolEngine {
|
|
|
4840
5443
|
this.#watchedConnections.add(connection)
|
|
4841
5444
|
watch(deviceId, (state) => {
|
|
4842
5445
|
if (state !== 'offline' && state !== 'identity-mismatch') return
|
|
4843
|
-
|
|
5446
|
+
// A plain `offline` keeps the remembered command bodies: see `#forgetDevice`.
|
|
5447
|
+
this.#forgetDevice(deviceId, { dropCommandBodies: state === 'identity-mismatch' })
|
|
4844
5448
|
})
|
|
4845
5449
|
}
|
|
4846
5450
|
|
|
@@ -4853,8 +5457,16 @@ class CherryToolEngine {
|
|
|
4853
5457
|
* leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
|
|
4854
5458
|
* the previous pairing's snapshots and a long-lived process does not
|
|
4855
5459
|
* accumulate entries for devices that no longer exist.
|
|
5460
|
+
*
|
|
5461
|
+
* The remembered command bodies obey a NARROWER seam than the read caches:
|
|
5462
|
+
* `dropCommandBodies` is passed by the callers that invalidate the PAIRING
|
|
5463
|
+
* (unpair, identity-mismatch), never by an ordinary `offline`. What a body
|
|
5464
|
+
* replays against — the peer's command ledger — survives a reconnect, and the
|
|
5465
|
+
* body is the only local copy of the exact bytes that ledger will accept, so
|
|
5466
|
+
* a link drop that emptied it would cost the recovery itself, where the read
|
|
5467
|
+
* caches merely pay one re-walk.
|
|
4856
5468
|
*/
|
|
4857
|
-
#forgetDevice(deviceId: string): void {
|
|
5469
|
+
#forgetDevice(deviceId: string, options: { dropCommandBodies?: boolean } = {}): void {
|
|
4858
5470
|
const prefix = `${deviceId}\u0000`
|
|
4859
5471
|
for (const key of [...this.#revisionCache.keys()]) {
|
|
4860
5472
|
if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key)
|
|
@@ -4875,6 +5487,116 @@ class CherryToolEngine {
|
|
|
4875
5487
|
for (const key of [...this.#messageCache.keys()]) {
|
|
4876
5488
|
if (key.startsWith(prefix)) this.#dropCachedMessage(key)
|
|
4877
5489
|
}
|
|
5490
|
+
// The remembered command bodies (issues #115/#118) differ from every cache above:
|
|
5491
|
+
// what they replay against is the peer's command LEDGER, and that survives
|
|
5492
|
+
// an ordinary reconnect — so they are dropped only when the PAIRING is
|
|
5493
|
+
// invalidated (`dropCommandBodies`: unpair, identity-mismatch), because a
|
|
5494
|
+
// body minted against one pairing must not be replayed to whatever desktop
|
|
5495
|
+
// answers under this device id next.
|
|
5496
|
+
//
|
|
5497
|
+
// The residual, stated rather than closed: GRANT_REVOKED also surfaces as
|
|
5498
|
+
// `offline`, so a voided pairing's entries survive until the re-pair path
|
|
5499
|
+
// (unpair / identity-mismatch) or the 128-entry FIFO retires them; a same-id
|
|
5500
|
+
// retry across a silent re-pair replays a body the new peer admits as a NEW
|
|
5501
|
+
// command — and the caller asked for that send.
|
|
5502
|
+
if (options.dropCommandBodies === true) {
|
|
5503
|
+
for (const key of [...this.#commandBodies.keys()]) {
|
|
5504
|
+
if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key)
|
|
5505
|
+
}
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
/**
|
|
5510
|
+
* The command remembered for one caller-supplied `commandId`, keyed
|
|
5511
|
+
* `<deviceId>\0<commandId>` — or `undefined` when this device has never
|
|
5512
|
+
* emitted one (a first attempt, or an entry the FIFO evicted).
|
|
5513
|
+
*
|
|
5514
|
+
* The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
|
|
5515
|
+
* a miss:
|
|
5516
|
+
*
|
|
5517
|
+
* - no entry → a fresh call;
|
|
5518
|
+
* - an entry under the caller's own method → match the fingerprint, then
|
|
5519
|
+
* replay or refuse;
|
|
5520
|
+
* - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
|
|
5521
|
+
* minted for `agent.messages.send` is not a respond retry, and collapsing
|
|
5522
|
+
* that case into "no entry" would send a respond under a send's id and
|
|
5523
|
+
* let the PEER answer the conflict one round trip later.
|
|
5524
|
+
*/
|
|
5525
|
+
#recallCommandBody(deviceId: string, commandId: string): RememberedCommand | undefined {
|
|
5526
|
+
return this.#commandBodies.get(this.#cacheKey(deviceId, commandId))
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
/**
|
|
5530
|
+
* TEST SEAM — the remembered bodies, as one `commandId` per device.
|
|
5531
|
+
*
|
|
5532
|
+
* The memory itself is PRIVATE, and stays that way for callers. What a test
|
|
5533
|
+
* needs to assert is its bookkeeping — that the bound evicts the oldest entry
|
|
5534
|
+
* and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
|
|
5535
|
+
* and neither can be observed through a real retry: a retry of an evicted id
|
|
5536
|
+
* is indistinguishable from a retry of an absent one, because both are refused
|
|
5537
|
+
* locally by the same rule. Keys are rendered WITHOUT the device component so
|
|
5538
|
+
* a test reads ids, not cache internals.
|
|
5539
|
+
*/
|
|
5540
|
+
rememberedCommandIds(deviceId: string): string[] {
|
|
5541
|
+
const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`
|
|
5542
|
+
return [...this.#commandBodies.keys()]
|
|
5543
|
+
.filter((key) => key.startsWith(prefix))
|
|
5544
|
+
.map((key) => key.slice(prefix.length))
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
/**
|
|
5548
|
+
* TEST SEAM — fill the memory the way a long-lived process would: one entry
|
|
5549
|
+
* per id, WITHOUT spending a wire call on each. The bound and the FIFO order
|
|
5550
|
+
* are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
|
|
5551
|
+
* is what makes this a test of the real policy rather than of a mock of it.
|
|
5552
|
+
*/
|
|
5553
|
+
rememberCommandBodyForTest(deviceId: string, commandId: string): void {
|
|
5554
|
+
this.#rememberCommandBody(deviceId, commandId, {
|
|
5555
|
+
method: 'agent.messages.send',
|
|
5556
|
+
fingerprint: { sessionId: 'session-for-test', text: 'text-for-test' },
|
|
5557
|
+
body: {
|
|
5558
|
+
commandId,
|
|
5559
|
+
sessionId: 'session-for-test',
|
|
5560
|
+
text: 'text-for-test',
|
|
5561
|
+
expectedIdleRevision: '0',
|
|
5562
|
+
},
|
|
5563
|
+
})
|
|
5564
|
+
}
|
|
5565
|
+
|
|
5566
|
+
/**
|
|
5567
|
+
* Remembers the exact body one caller-supplied `commandId` was ISSUED with,
|
|
5568
|
+
* evicting this device's oldest entry when it is at the bound.
|
|
5569
|
+
*
|
|
5570
|
+
* FIFO, per device: `Map` insertion order IS the policy, the same deliberate
|
|
5571
|
+
* choice the session caches document, and a `set` on an EXISTING key keeps its
|
|
5572
|
+
* position — a refresh is a rewrite, not a use, so the policy stays
|
|
5573
|
+
* insert-order rather than becoming LRU. One line per eviction, naming the
|
|
5574
|
+
* device and the bound only.
|
|
5575
|
+
*/
|
|
5576
|
+
#rememberCommandBody(
|
|
5577
|
+
deviceId: string,
|
|
5578
|
+
commandId: string,
|
|
5579
|
+
remembered: RememberedCommand,
|
|
5580
|
+
): void {
|
|
5581
|
+
const key = this.#cacheKey(deviceId, commandId)
|
|
5582
|
+
if (!this.#commandBodies.has(key)) {
|
|
5583
|
+
let own = 0
|
|
5584
|
+
let oldest: string | undefined
|
|
5585
|
+
for (const candidate of this.#commandBodies.keys()) {
|
|
5586
|
+
if (!candidate.startsWith(`${deviceId}\u0000`)) continue
|
|
5587
|
+
own += 1
|
|
5588
|
+
if (oldest === undefined) oldest = candidate
|
|
5589
|
+
}
|
|
5590
|
+
if (own >= this.#commandBodyEntriesPerDevice && oldest !== undefined) {
|
|
5591
|
+
this.#commandBodies.delete(oldest)
|
|
5592
|
+
console.warn(
|
|
5593
|
+
`[cherry-remote] remembered command bodies for device ${deviceId} reached their ` +
|
|
5594
|
+
`${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of ` +
|
|
5595
|
+
'that commandId now re-derives its body, and the peer decides whether it replays)',
|
|
5596
|
+
)
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
this.#commandBodies.set(key, remembered)
|
|
4878
5600
|
}
|
|
4879
5601
|
|
|
4880
5602
|
/**
|
|
@@ -5997,15 +6719,25 @@ class CherryToolEngine {
|
|
|
5997
6719
|
* identity object: the verdict is already known and true, and only the
|
|
5998
6720
|
* content behind it failed.
|
|
5999
6721
|
*
|
|
6000
|
-
* `aggregateError` is
|
|
6001
|
-
*
|
|
6002
|
-
*
|
|
6722
|
+
* `aggregateError` is a TOOL RESULT, not a log line — see
|
|
6723
|
+
* {@link defineCherryTool} for the product decision (issue #161): a result is
|
|
6724
|
+
* the MODEL's signal, so it is not bounded by the logging rule, and the
|
|
6725
|
+
* 200-character clip here is this field's own choice.
|
|
6726
|
+
*
|
|
6727
|
+
* The LOG line IS the logging face, and it renders the failure as ONE bounded
|
|
6728
|
+
* string through {@link failureText} (issue #161). An assembly failure is
|
|
6729
|
+
* usually a peer RPC refusal — its `error.message` is the peer's own text,
|
|
6730
|
+
* capped at 512 characters by the failure schema — and the old line
|
|
6731
|
+
* interpolated it whole: one 512-char refusal made a 654-character line for
|
|
6732
|
+
* the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
|
|
6733
|
+
* RESULT keeps the raw message's first 200 characters; a bound on the log
|
|
6734
|
+
* does not change what the model receives.
|
|
6003
6735
|
*/
|
|
6004
6736
|
#assemblyFailed(identity: Record<string, unknown>, error: unknown): Record<string, unknown> {
|
|
6005
6737
|
const message = error instanceof Error ? error.message : String(error)
|
|
6006
6738
|
console.warn(
|
|
6007
6739
|
`[cherry-remote] the ${String(identity['status'])} answer for session ${String(identity['sessionId'])} ` +
|
|
6008
|
-
`could not assemble its content (${
|
|
6740
|
+
`could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`,
|
|
6009
6741
|
)
|
|
6010
6742
|
return {
|
|
6011
6743
|
...identity,
|
|
@@ -6101,12 +6833,14 @@ class CherryToolEngine {
|
|
|
6101
6833
|
const historyRevision = optionalString(session['historyRevision'])
|
|
6102
6834
|
if (historyRevision === undefined) return { messages: [] }
|
|
6103
6835
|
|
|
6836
|
+
// The SAME dialect `cherry_read` sends, from the same helper (issue #105).
|
|
6837
|
+
const messageList = messageListRequest(connection)
|
|
6104
6838
|
const page = await this.#aggregate(
|
|
6105
6839
|
connection,
|
|
6106
6840
|
'agent.messages.list',
|
|
6107
|
-
{ sessionId, historyRevision },
|
|
6841
|
+
{ sessionId, historyRevision, ...messageList.params },
|
|
6108
6842
|
(result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
|
|
6109
|
-
{ limit: TERMINAL_AGGREGATE_MESSAGES, deadline },
|
|
6843
|
+
{ limit: TERMINAL_AGGREGATE_MESSAGES, deadline, maxPerPage: messageList.maxPerPage },
|
|
6110
6844
|
)
|
|
6111
6845
|
|
|
6112
6846
|
const messages: CherryReadMessage[] = []
|
|
@@ -6181,11 +6915,24 @@ class CherryToolEngine {
|
|
|
6181
6915
|
try {
|
|
6182
6916
|
layer = this.#deps.subs(deviceId)
|
|
6183
6917
|
} catch (error) {
|
|
6184
|
-
|
|
6918
|
+
// ONE bounded STRING (issue #161). This accessor's throw is LOCAL (no
|
|
6919
|
+
// record for the device, or the identity never loaded), but the arm is
|
|
6920
|
+
// converted with its sibling so the background re-ensure has ONE failure
|
|
6921
|
+
// shape and no branch can hand the console an unbounded object.
|
|
6922
|
+
console.warn(
|
|
6923
|
+
`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`,
|
|
6924
|
+
)
|
|
6185
6925
|
return
|
|
6186
6926
|
}
|
|
6187
6927
|
void layer.ensure(sessionId).catch((error: unknown) => {
|
|
6188
|
-
|
|
6928
|
+
// ONE bounded STRING (issue #161): `ensure` reaches the peer, so the
|
|
6929
|
+
// rejection message is the peer's JSON-RPC `error.message` verbatim (up
|
|
6930
|
+
// to the 512-char failure schema cap) or a transport message. The old
|
|
6931
|
+
// form passed the error OBJECT, so the console printed its stack plus
|
|
6932
|
+
// every own property unbounded.
|
|
6933
|
+
console.warn(
|
|
6934
|
+
`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
|
|
6935
|
+
)
|
|
6189
6936
|
})
|
|
6190
6937
|
}
|
|
6191
6938
|
|