@animalabs/agent-framework 0.25.0 → 0.26.1

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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/src/framework.d.ts +98 -9
  3. package/dist/src/framework.d.ts.map +1 -1
  4. package/dist/src/framework.js +422 -91
  5. package/dist/src/framework.js.map +1 -1
  6. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  7. package/dist/src/mcpl/channel-registry.js +24 -3
  8. package/dist/src/mcpl/channel-registry.js.map +1 -1
  9. package/dist/src/mcpl/push-coalescer.d.ts +34 -11
  10. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  11. package/dist/src/mcpl/push-coalescer.js +61 -27
  12. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  13. package/dist/src/mcpl/references.d.ts +11 -5
  14. package/dist/src/mcpl/references.d.ts.map +1 -1
  15. package/dist/src/mcpl/references.js +21 -6
  16. package/dist/src/mcpl/references.js.map +1 -1
  17. package/dist/src/tool-image-ledger.d.ts +26 -3
  18. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  19. package/dist/src/tool-image-ledger.js +31 -4
  20. package/dist/src/tool-image-ledger.js.map +1 -1
  21. package/dist/src/tool-result-history.d.ts +12 -0
  22. package/dist/src/tool-result-history.d.ts.map +1 -1
  23. package/dist/src/tool-result-history.js +23 -1
  24. package/dist/src/tool-result-history.js.map +1 -1
  25. package/dist/src/types/events.d.ts +3 -1
  26. package/dist/src/types/events.d.ts.map +1 -1
  27. package/package.json +3 -3
  28. package/src/framework.ts +427 -93
  29. package/src/mcpl/channel-registry.ts +23 -4
  30. package/src/mcpl/push-coalescer.ts +83 -34
  31. package/src/mcpl/references.ts +22 -7
  32. package/src/tool-image-ledger.ts +31 -4
  33. package/src/tool-result-history.ts +22 -1
  34. package/src/types/events.ts +3 -1
@@ -1708,6 +1708,9 @@ export class ChannelRegistry {
1708
1708
  responder?: Responder,
1709
1709
  ): Promise<void> {
1710
1710
  const results: ChannelIncomingMessageResult[] = [];
1711
+ // Set when a coalesced message was admitted but its acknowledgement
1712
+ // failed: the request is then answered with this error (below).
1713
+ let requestError: { code: number; message: string; field?: string } | undefined;
1711
1714
  // The batch came on the transport open now: a close after this point
1712
1715
  // (transportClosed) leaves its traffic nothing to confirm.
1713
1716
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
@@ -1919,10 +1922,26 @@ export class ChannelRegistry {
1919
1922
  if (result.accepted) markAccepted(firstView === true);
1920
1923
  results.push(result);
1921
1924
  } catch (error) {
1922
- const err = error as Error & { code?: number; field?: string };
1925
+ const err = error as Error & { code?: number; field?: string; admitted?: boolean };
1923
1926
  // -32602 is answered coalesce_invalid; anything else as its message,
1924
1927
  // a plain Error standing for an internal error (-32603).
1925
- this.traceCoalesceRejection(serverId, message, err, typeof err.code === 'number' ? err.code : -32603);
1928
+ const code = typeof err.code === 'number' ? err.code : -32603;
1929
+ this.traceCoalesceRejection(serverId, message, err, code);
1930
+ if (err.admitted && responder?.respondError) {
1931
+ // Admitted, with its effects in: only the acknowledgement failed,
1932
+ // so `accepted: false`, which says the message wasn't admitted,
1933
+ // would be untrue, and a producer would hold it as never
1934
+ // delivered. A per-message result can't say "may have been
1935
+ // admitted" (§14.3); an error for the request is the spec's
1936
+ // answer for that. Nothing after this message is admitted, since
1937
+ // the producer sends the request's messages again: a coalesced
1938
+ // one is answered from its receipt, and an uncoalesced one
1939
+ // before this message is admitted again. (A responder without
1940
+ // respondError, which only an in-process caller passes, gets the
1941
+ // per-message answer below: it can't be told anything else.)
1942
+ requestError = { code, message: err.message, ...(err.field ? { field: err.field } : {}) };
1943
+ break;
1944
+ }
1926
1945
  results.push({
1927
1946
  messageId: message.messageId,
1928
1947
  accepted: false,
@@ -1950,8 +1969,8 @@ export class ChannelRegistry {
1950
1969
  });
1951
1970
  }
1952
1971
 
1953
- const result: ChannelsIncomingResult = { results };
1954
- responder?.respond(result);
1972
+ if (requestError) responder!.respondError!(requestError.code, requestError.message, { field: requestError.field });
1973
+ else responder?.respond({ results } satisfies ChannelsIncomingResult);
1955
1974
 
1956
1975
  this.emitTraceFn({
1957
1976
  type: 'mcpl:channels-incoming',
@@ -32,9 +32,14 @@ export type CoalesceOutcome = typeof COALESCE_OUTCOMES[number];
32
32
  class CancelledRender extends Error {}
33
33
 
34
34
  export class CoalesceError extends Error {
35
- constructor(readonly field: string, message: string, readonly code = -32602) {
35
+ /** Set on a failure that came after the occurrence's effects: it was
36
+ * admitted, and only its acknowledgement failed (acknowledge). A lane
37
+ * must not answer it as not admitted. */
38
+ readonly admitted: boolean;
39
+ constructor(readonly field: string, message: string, readonly code = -32602, options: { admitted?: true } = {}) {
36
40
  super(message);
37
41
  this.name = 'CoalesceError';
42
+ this.admitted = options.admitted === true;
38
43
  }
39
44
  }
40
45
 
@@ -210,7 +215,16 @@ interface SubjectState<E> {
210
215
  touchedAt: number;
211
216
  }
212
217
 
213
- interface ReceiptEntry { result: PushEventResult; at: number; committed: boolean; firstView?: true }
218
+ interface ReceiptEntry {
219
+ result: PushEventResult;
220
+ at: number;
221
+ committed: boolean;
222
+ firstView?: true;
223
+ /** Set while the receipt's record isn't written (recordReceipt failed):
224
+ * what a rewrite keeps from the admission. The rest of the record is the
225
+ * subject's state when it's written. */
226
+ unwritten?: { subject: string; born?: SubjectState<unknown>['history'] };
227
+ }
214
228
 
215
229
  export interface CoalescingSnapshot {
216
230
  version: 1;
@@ -244,15 +258,18 @@ export interface CoalescingReceiptRecord {
244
258
  /** History the subject was created with, for a subject first seen by this receipt. */
245
259
  born?: SubjectState<unknown>['history'];
246
260
  /** The subject's first view had ended by this acceptance
247
- * (SubjectState.firstViewEnded), so a crash before the snapshot flush
248
- * can't forget the end. A record written before this field existed has
249
- * none and reads as not ended, as a snapshot does. */
261
+ * (SubjectState.firstViewEnded), or by the time a retry wrote the record,
262
+ * so a crash before the snapshot flush can't forget the end. A record
263
+ * written before this field existed has none and reads as not ended, as a
264
+ * snapshot does. */
250
265
  firstViewEnded?: true;
251
266
  /** The subject's pending batch AFTER this acceptance: the accepted work
252
267
  * itself (so a crash before the snapshot flush cannot lose it while keeping
253
268
  * the receipt that suppresses its retry), or `null` when the acceptance
254
269
  * left no batch — a retraction or plain replacement must be recoverable
255
- * too. Records replay in order, so the last one per subject wins. */
270
+ * too. Records replay in order, so the last one per subject wins. A
271
+ * record a retry writes (its first write failed) takes the batch the
272
+ * subject has then, a rendering one included. */
256
273
  batch: { latest: CoalescedOccurrence<unknown>; notices: DeferredNotice[]; dropped: number } | null;
257
274
  }
258
275
 
@@ -298,11 +315,13 @@ export interface CoalescerHost<E> {
298
315
  * be recoverable before the RPC is issued, §3.2). Throws on failure. */
299
316
  saveNow?(snapshot: CoalescingSnapshot): void;
300
317
  /** Persist one receipt now, before the acceptance is acknowledged. Throws
301
- * on failure, which fails the acceptance (the producer retries). */
318
+ * on failure, which fails that answer: the receipt is kept unwritten, and
319
+ * the producer's retry writes it again rather than admitting it again. */
302
320
  recordReceipt?(record: CoalescingReceiptRecord): void;
303
321
  /** One call per ADMITTED occurrence (never for a producer retry the receipt
304
- * deduplicates), after its receipt is recorded. Observation only: a throw
305
- * is contained here and never fails the acceptance. */
322
+ * deduplicates), after its effects, whether or not its receipt could be
323
+ * written. Observation only: a throw is contained here and never fails the
324
+ * acceptance. */
306
325
  accepted?(occurrence: CoalescedOccurrence<E>): void;
307
326
  /** Make every write so far DURABLE (an fsync; the coalescer requests each
308
327
  * commit under its lock, so no two acceptances share one).
@@ -540,17 +559,27 @@ export class PushCoalescer<E = unknown> {
540
559
  return entry;
541
560
  }
542
561
 
543
- /** Acknowledge a receipt: durable first. A receipt whose commit failed is
544
- * committed now (no effects are repeated); if that fails again, so does
545
- * the acknowledgement, and the producer retries once more. */
562
+ /** Acknowledge a receipt: durable first. A receipt whose record couldn't be
563
+ * written is written now, and one whose commit failed is committed now (no
564
+ * effects are repeated); if either fails again, so does the
565
+ * acknowledgement, and the producer retries once more. */
546
566
  private async acknowledge(key: string, entry: ReceiptEntry, subject: string, eventId: string): Promise<PushEventResult> {
567
+ if (entry.unwritten) {
568
+ try {
569
+ this.recordReceipt(key, entry, entry.unwritten);
570
+ } catch (error) {
571
+ this.host.audit({ kind: 'persist-failed', subject, eventId, error: String(error) });
572
+ throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000, { admitted: true });
573
+ }
574
+ delete entry.unwritten;
575
+ }
547
576
  if (!entry.committed) {
548
577
  try {
549
578
  await this.host.commit?.();
550
579
  entry.committed = true;
551
580
  } catch (error) {
552
581
  this.host.audit({ kind: 'persist-failed', subject, eventId, error: String(error) });
553
- throw new CoalesceError('eventId', 'host could not make the acceptance durable; retry', -32000);
582
+ throw new CoalesceError('eventId', 'host could not make the acceptance durable; retry', -32000, { admitted: true });
554
583
  }
555
584
  }
556
585
  return structuredClone(entry.result);
@@ -571,9 +600,11 @@ export class PushCoalescer<E = unknown> {
571
600
  }
572
601
 
573
602
  private async admit(occurrence: CoalescedOccurrence<E>): Promise<PushEventResult> {
574
- if (this.suspended) throw new CoalesceError('serverId', 'host is stopping', -32000);
575
- const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key);
576
603
  const duplicate = this.receiptEntry(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId));
604
+ // A retry of an occurrence admitted before the stop is refused too, but
605
+ // as one admitted: its effects are in, so no lane may say it wasn't.
606
+ if (this.suspended) throw new CoalesceError('serverId', 'host is stopping', -32000, duplicate ? { admitted: true } : {});
607
+ const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key);
577
608
  if (duplicate) {
578
609
  // A producer retrying acknowledged work is also a chance to notice a
579
610
  // batch left asleep by a failed freeze: wake it now, without effects.
@@ -605,19 +636,18 @@ export class PushCoalescer<E = unknown> {
605
636
  state.touchedAt = this.now();
606
637
  const result: PushEventResult = { accepted: true, coalesce: { outcome, ...(priorEventId ? { priorEventId } : {}) } };
607
638
  const receiptKey = coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId);
608
- const entry: ReceiptEntry = { result: structuredClone(result), at: this.now(), committed: false, ...(this.deliveredFirstView ? { firstView: true as const } : {}) };
609
- try {
610
- this.host.recordReceipt?.({
611
- key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}),
612
- ...(state.firstViewEnded ? { firstViewEnded: true as const } : {}),
613
- batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence<unknown>, notices: state.batch.notices, dropped: state.batch.dropped }) : null,
614
- });
615
- } catch (error) {
616
- // Not acknowledged: the work would be lost while its receipt suppressed
617
- // a retry. The producer retries within the window.
618
- this.host.audit({ kind: 'persist-failed', subject, eventId: occurrence.eventId, error: String(error) });
619
- throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000);
620
- }
639
+ // The effects are done, so the receipt is kept whatever its record's
640
+ // write does: a retry is answered from it, never admitted again (RFC-006
641
+ // §3.1). Its record is written and made durable before an acknowledgement
642
+ // (acknowledge), by this answer or a retry's. While this process lives,
643
+ // the in-memory receipt deduplicates a retry, which writes or commits
644
+ // what failed. The snapshot carries it too, so a later save and commit
645
+ // keep it across a restart; a kill before either leaves nothing of it,
646
+ // and the retry is a fresh acceptance.
647
+ const entry: ReceiptEntry = {
648
+ result: structuredClone(result), at: this.now(), committed: false, unwritten: { subject, ...(born ? { born } : {}) },
649
+ ...(this.deliveredFirstView ? { firstView: true as const } : {}),
650
+ };
621
651
  this.receipts.set(receiptKey, entry);
622
652
  try {
623
653
  this.host.accepted?.(occurrence);
@@ -626,12 +656,31 @@ export class PushCoalescer<E = unknown> {
626
656
  }
627
657
  this.prune();
628
658
  this.persist();
629
- // Written but not yet durable: the in-memory receipt deduplicates a retry
630
- // while this process lives, and the retry commits it (acknowledge); after
631
- // a kill nothing of it exists and the retry is a fresh acceptance.
632
659
  return this.acknowledge(receiptKey, entry, subject, occurrence.eventId);
633
660
  }
634
661
 
662
+ /** Write a receipt's record: what the admission fixed (`fixed`), with the
663
+ * subject's first-view end and pending batch as they are now. A record
664
+ * written by a retry follows any written since its admission, and records
665
+ * replay in order, so it carries the batch the subject has now, not the
666
+ * one it had then. That includes a batch whose render has started, which
667
+ * the render-start snapshot holds too: a `null` would clear it at a
668
+ * restore, and a crash before the render's content is stored would lose
669
+ * the accepted work. (At an admission no batch is rendering without a
670
+ * pending one: a plain occurrence or a retraction cancels the render, and
671
+ * a deferred notice opens a batch.) Throws when the host's write does. */
672
+ private recordReceipt(key: string, entry: ReceiptEntry, fixed: { subject: string; born?: SubjectState<unknown>['history'] }): void {
673
+ const state = this.subjects.get(fixed.subject);
674
+ const batch = state?.batch ?? state?.rendering?.batch;
675
+ this.host.recordReceipt?.({
676
+ // A birth is the subject's only while the coalescer still holds it: one
677
+ // evicted since its admission is untracked, `unknown` (prune).
678
+ key, result: entry.result, at: entry.at, subject: fixed.subject, ...(fixed.born && state ? { born: fixed.born } : {}),
679
+ ...(state?.firstViewEnded ? { firstViewEnded: true as const } : {}),
680
+ batch: batch ? structuredClone({ latest: batch.latest as CoalescedOccurrence<unknown>, notices: batch.notices, dropped: batch.dropped }) : null,
681
+ });
682
+ }
683
+
635
684
  private subjectFor(subject: string, occurrence: CoalescedOccurrence<E>): SubjectState<E> {
636
685
  let state = this.subjects.get(subject);
637
686
  if (!state) {
@@ -1193,9 +1242,9 @@ export class PushCoalescer<E = unknown> {
1193
1242
  if (r.batch === undefined) continue; // pre-round-3 record: no batch information
1194
1243
  // The subject's batch AFTER that acceptance, newer than the snapshot:
1195
1244
  // the accepted deferred work itself, or null for a withdrawal /
1196
- // replacement that cleared it. It never began rendering before the
1197
- // record was written, but the snapshot cannot say whether it did
1198
- // afterwards: take the fallback.
1245
+ // replacement that cleared it. Its render may have started before the
1246
+ // record was written (a retry's record) or after, and the snapshot
1247
+ // cannot say whether it ran: take the fallback.
1199
1248
  const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now };
1200
1249
  state.batch = r.batch
1201
1250
  ? { latest: r.batch.latest as CoalescedOccurrence<E>, notices: r.batch.notices, dropped: r.batch.dropped, noRender: true }
@@ -454,7 +454,9 @@ export function referenceStubOrNull(block: unknown, provenance?: string, serverI
454
454
  * RFC-005 treatment for one MCPL wire block, for a script's structured
455
455
  * result, which carries blocks rather than text. It makes the decision
456
456
  * referenceStubOrNull makes for the model's lanes, since what a script prints
457
- * comes back to the model. A reference becomes the stub's facts as data,
457
+ * comes back to the model, except that a script reads every field, so inline
458
+ * data beside a uri of any value is withheld, where the model's lanes, which
459
+ * show no uri, withhold it beside a string one. A reference becomes the stub's facts as data,
458
460
  * without its URI: the host's reference id, what the server said about it in
459
461
  * this block (its labels stripped and bounded as in the stub), and `savedTo`,
460
462
  * the workspace path its payload was fetched to, or null when it wasn't
@@ -462,18 +464,31 @@ export function referenceStubOrNull(block: unknown, provenance?: string, serverI
462
464
  * counts as expired also carries `expired`, the reason the stub gives in
463
465
  * place of advising fetch_reference. The reference is registered bound to
464
466
  * `serverId`, the server whose tool returned it, as the model's lanes bind
465
- * a tool result's references, so the fetch its id offers can be made. A
466
- * block the classifier
467
+ * a tool result's references, so the fetch its id offers can be made. With
468
+ * no `serverId`, as for a module's result, it's registered as the model's
469
+ * history registers a module's: the record its uri already has, bound where
470
+ * it arrived (a module's ctx.callTool registers an MCPL peer's references),
471
+ * or a new one bound to no server. A block the classifier
467
472
  * rejects becomes the notice the model's lanes show, and inline data that
468
- * also claims a bulk disposition the withheld notice, each as a text block.
473
+ * also claims a bulk disposition, or a uri of any value, the withheld
474
+ * notice, each as a text block.
469
475
  * A text block, or inline data that claims nothing, is returned as sent.
470
476
  */
471
- export function referenceBlockForScript(block: unknown, serverId: string): unknown {
477
+ export function referenceBlockForScript(block: unknown, serverId?: string): unknown {
472
478
  const c = classifyBlock(block);
473
479
  switch (c.kind) {
474
480
  case 'text': return block;
475
- case 'inline':
476
- return c.contradiction ? { type: 'text', text: INLINE_WITHHELD_TEXT } : block;
481
+ case 'inline': {
482
+ // A script reads every field, as history's JSON writes it, so inline
483
+ // data beside a uri of any value is withheld here, not only beside a
484
+ // string one as in the model's lanes, which show no uri: an object can
485
+ // carry a credential. An absent or null uri carries none; a string one,
486
+ // an empty one included, is already the classifier's contradiction.
487
+ const uri = (block as { uri?: unknown }).uri;
488
+ return c.contradiction || (uri !== undefined && uri !== null)
489
+ ? { type: 'text', text: INLINE_WITHHELD_TEXT }
490
+ : block;
491
+ }
477
492
  case 'invalid': return { type: 'text', text: c.shown };
478
493
  case 'reference': {
479
494
  const t = c.testimony;
@@ -428,6 +428,19 @@ export const TOOL_RESULT_NOTICE_ROOM = 640;
428
428
  * share. `keptCap` is where a withheld stub's kept fields are cut, leaving
429
429
  * the stub's notice its room (tool-result-guard.ts). With no bound, each
430
430
  * keeps the cap.
431
+ *
432
+ * Each result is then stored within its share, or at its least where its
433
+ * notice is longer than the share: whole where it's no longer than the
434
+ * notice (cutStoredToolResultToFit), otherwise its notice alone. So a reader's
435
+ * cut reaches a notice only when the turn, each result at its least, passes
436
+ * the bound. One edge: notices differ in length (by a few characters for
437
+ * the content's digits, more where call ids differ in length), and where a
438
+ * share falls between two of them, a result between its own notice and the
439
+ * share stays whole above its least, by up to that difference.
440
+ * A turn that then passes the bound by those characters loses the end of
441
+ * its last notice to the reader's cut, under context-manager's mark (whose
442
+ * token count is the notice's, not the result's); the notice's path and read
443
+ * call come before that end.
431
444
  */
432
445
  export function toolResultShare(cap: number, bound: number | undefined, n: number): { cap: number; bound: number | undefined; keptCap: number } {
433
446
  if (bound === undefined) return { cap, bound, keptCap: cap };
@@ -447,8 +460,17 @@ export function toolResultShare(cap: number, bound: number | undefined, n: numbe
447
460
  * the rest left out and uncounted, as past MAX_KEPT_IMAGE_SLOTS. A bound
448
461
  * under the notice's room can't hold the notice. With no bound, or where the
449
462
  * cut at the cap already fits, the cut is cutStoredToolResult's.
463
+ *
464
+ * `notice`, given where `bound` is a text's share of one turn's bound, is the
465
+ * length of the notice a cut that keeps no head would carry
466
+ * (spillNoticeLength). A text no longer than what that cut would store in
467
+ * its place, the notice and, where it keeps no slot, the note on the slots it
468
+ * leaves out, is kept whole: a cut would only lengthen it. Only a cut that
469
+ * keeps no head can meet that, since one that keeps a head is cut past the
470
+ * notice's room. A cut that keeps slots is measured by its notice alone, as
471
+ * the slots it keeps are the text's own.
450
472
  */
451
- export function cutStoredToolResultToFit(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): StoredCut {
473
+ export function cutStoredToolResultToFit(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined, notice?: number): StoredCut {
452
474
  let cut = cutStoredToolResult(text, cap, shown);
453
475
  if (bound === undefined || text.length <= cap) return cut;
454
476
  const room = bound - TOOL_RESULT_NOTICE_ROOM;
@@ -456,6 +478,10 @@ export function cutStoredToolResultToFit(text: string, cap: number, shown: Shown
456
478
  cut = cutStoredToolResult(text, Math.max(0, room - formatKeptImageSlots(cut).length), shown);
457
479
  }
458
480
  while (cut.kept.length > 0 && formatKeptImageSlots(cut).length > room) cut = { ...cut, kept: cut.kept.slice(1) };
481
+ if (notice !== undefined && cut.head.length === 0
482
+ && text.length <= notice + (cut.kept.length === 0 ? formatKeptImageSlots(cut).length : 0)) {
483
+ return { head: text, kept: [], fell: 0 };
484
+ }
459
485
  return cut;
460
486
  }
461
487
 
@@ -464,10 +490,11 @@ export function cutStoredToolResultToFit(text: string, cap: number, shown: Shown
464
490
  * text is longer than `cap`, those the cut keeps, in head and after the
465
491
  * notice (cutStoredToolResultToFit, under the readers' `bound`). The notice
466
492
  * itself holds none. For counting a result before its stored copy is
467
- * written, as it will count after.
493
+ * written, as it will count after, so `notice` is the one spillOrTruncate
494
+ * gives the cut (spillNoticeLength).
468
495
  */
469
- export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): ParsedImagePlaceholder[] {
470
- const cut = cutStoredToolResultToFit(text, cap, shown, bound);
496
+ export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined, notice?: number): ParsedImagePlaceholder[] {
497
+ const cut = cutStoredToolResultToFit(text, cap, shown, bound, notice);
471
498
  return parseImagePlaceholders(cut.head + formatKeptImageSlots(cut));
472
499
  }
473
500
 
@@ -19,7 +19,7 @@
19
19
  * is withheld.
20
20
  */
21
21
 
22
- import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js';
22
+ import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceBlockForScript, referenceStubOrNull } from './mcpl/references.js';
23
23
  import { NON_STRING_TEXT_PLACEHOLDER } from './mcpl/visible-content.js';
24
24
  import { formatToolImagePlaceholder, toolResultCutAt } from './tool-image-ledger.js';
25
25
 
@@ -237,6 +237,27 @@ function shownInHistory(value: unknown): unknown {
237
237
  return referenceStubOrNull(b, 'from tool result') ?? value;
238
238
  }
239
239
 
240
+ /**
241
+ * A value of a tool result's data as a script reads it, by history's rules
242
+ * (shownInHistory), as a JSON replacer applies them at every depth: what
243
+ * RFC-005 governs is shown as the script lane shows it, a reference as its
244
+ * stub's facts without its uri and an invalid one as the notice
245
+ * (referenceBlockForScript), and inline data that also claims a uri of any
246
+ * value or a disposition as the withheld notice, each as a text block. Any
247
+ * other value is itself. What it gives is either a stub or a notice, which
248
+ * RFC-005 doesn't govern, or the value itself, which it gives again, so
249
+ * applying it twice gives what applying it once gave.
250
+ */
251
+ export function shownToScript(value: unknown): unknown {
252
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
253
+ const b = value as Record<string, unknown>;
254
+ if (!governedByRfc005(b)) return value;
255
+ if ((b.type === 'image' || b.type === 'audio') && typeof b.data === 'string') {
256
+ return hasUri(b.uri) || b.disposition !== undefined ? { type: 'text', text: INLINE_WITHHELD_TEXT } : value;
257
+ }
258
+ return referenceBlockForScript(b);
259
+ }
260
+
240
261
  /**
241
262
  * The text RFC-005 shows a value of a tool result's data as, when it shows it
242
263
  * as text rather than as its fields: a reference's stub, an invalid one's
@@ -291,7 +291,9 @@ export interface ToolCall {
291
291
  * text answer's envelope, over six: 7,456,527 at the default 128 MiB. On
292
292
  * Node, past about 1.5 GiB, the host's longest string bounds it instead.
293
293
  * A result with a `structured` value reaches a script as its content's
294
- * blocks as they are, beside that value as JSON, and the figure counts
294
+ * blocks, a module's or an MCPL peer's with RFC-005's treatment of what it
295
+ * governs (a reference as its stub's facts) and a standard peer's as sent,
296
+ * beside that value as JSON, and the figure counts
295
297
  * neither the value nor what the blocks carry past their text, such as each
296
298
  * further block's keys or an image's data, so a tool that returns one
297
299
  * leaves room for them; and a result nested past 2,000 levels, or whose parse