@animalabs/agent-framework 0.24.0 → 0.24.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 (65) hide show
  1. package/dist/src/agent.d.ts +5 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +6 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/clock-rows.d.ts +115 -0
  6. package/dist/src/clock-rows.d.ts.map +1 -0
  7. package/dist/src/clock-rows.js +222 -0
  8. package/dist/src/clock-rows.js.map +1 -0
  9. package/dist/src/framework.d.ts +95 -5
  10. package/dist/src/framework.d.ts.map +1 -1
  11. package/dist/src/framework.js +485 -74
  12. package/dist/src/framework.js.map +1 -1
  13. package/dist/src/index.d.ts +2 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/channel-registry.d.ts +9 -0
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +49 -6
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/protocol-family.js +1 -1
  22. package/dist/src/modules/workspace/index.d.ts +9 -1
  23. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  24. package/dist/src/modules/workspace/index.js +44 -8
  25. package/dist/src/modules/workspace/index.js.map +1 -1
  26. package/dist/src/timezone.d.ts +4 -2
  27. package/dist/src/timezone.d.ts.map +1 -1
  28. package/dist/src/timezone.js +4 -2
  29. package/dist/src/timezone.js.map +1 -1
  30. package/dist/src/tool-image-ledger.d.ts +46 -7
  31. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  32. package/dist/src/tool-image-ledger.js +60 -7
  33. package/dist/src/tool-image-ledger.js.map +1 -1
  34. package/dist/src/tool-result-guard.d.ts +10 -8
  35. package/dist/src/tool-result-guard.d.ts.map +1 -1
  36. package/dist/src/tool-result-guard.js +12 -10
  37. package/dist/src/tool-result-guard.js.map +1 -1
  38. package/dist/src/tool-result-history.d.ts +2 -1
  39. package/dist/src/tool-result-history.d.ts.map +1 -1
  40. package/dist/src/tool-result-history.js +2 -1
  41. package/dist/src/tool-result-history.js.map +1 -1
  42. package/dist/src/tune-out/coordinator.js +3 -3
  43. package/dist/src/tune-out/coordinator.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +27 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/events.d.ts +5 -1
  47. package/dist/src/types/events.d.ts.map +1 -1
  48. package/dist/src/types/framework.d.ts +7 -4
  49. package/dist/src/types/framework.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/agent.ts +6 -0
  52. package/src/clock-rows.ts +302 -0
  53. package/src/framework.ts +508 -79
  54. package/src/index.ts +2 -0
  55. package/src/mcpl/channel-registry.ts +54 -8
  56. package/src/mcpl/protocol-family.ts +1 -1
  57. package/src/modules/workspace/index.ts +47 -9
  58. package/src/timezone.ts +4 -2
  59. package/src/tool-image-ledger.ts +62 -8
  60. package/src/tool-result-guard.ts +12 -10
  61. package/src/tool-result-history.ts +2 -1
  62. package/src/tune-out/coordinator.ts +3 -3
  63. package/src/types/agent.ts +28 -0
  64. package/src/types/events.ts +5 -1
  65. package/src/types/framework.ts +7 -4
package/src/framework.ts CHANGED
@@ -124,12 +124,13 @@ import {
124
124
  import {
125
125
  TOOL_IMAGE_REF_RE,
126
126
  ToolImageLedger,
127
- cutStoredToolResult,
127
+ cutStoredToolResultToFit,
128
128
  formatKeptImageSlots,
129
129
  parseImagePlaceholders,
130
130
  sha256Hex,
131
131
  storedImageSlots,
132
132
  toolResultCutAt,
133
+ toolResultShare,
133
134
  type ParsedImagePlaceholder,
134
135
  type ShownRanges,
135
136
  } from './tool-image-ledger.js';
@@ -413,9 +414,12 @@ function malformedResponseContent(response: EventResponse): { where: string; pla
413
414
  * mid-turn message's text at chars/4 and its other blocks about as
414
415
  * context-manager's store prices them (injectedBlockTokens), and a small
415
416
  * per-block envelope allowance. On membrane's native tool path a mid-turn
416
- * message rides into the next round with its stored content as it is
417
- * (provideToolResults' injectedMessages); its XML tool path drops injected
418
- * messages, so there the estimate errs high. Deliberately a
417
+ * message rides into the next round as the boundary injects it, which is as
418
+ * the strategy renders it for the next compile, cut and with its images
419
+ * limited (renderForInjection), or its stored content where the strategy
420
+ * can't say (provideToolResults' injectedMessages). Its XML tool path declares that it
421
+ * can't carry injected messages, so the tool boundary injects none there and
422
+ * that term is zero. Deliberately a
419
423
  * ceiling-ish heuristic, but for a mid-turn message's media at the store's
420
424
  * flat prices — the cost of overestimating is one early recompile;
421
425
  * the cost of underestimating is the 400 this projection exists to prevent.
@@ -693,6 +697,7 @@ import type {
693
697
  } from './mcpl/types.js';
694
698
  import type { ContextInjection } from '@animalabs/context-manager';
695
699
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
700
+ import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
696
701
  import { firstFreeBranchName } from './branch-names.js';
697
702
  import {
698
703
  DEFAULT_DISCORD_AWARENESS_EMOJI,
@@ -841,10 +846,13 @@ type InboundResponder = {
841
846
  * For an entry whose write threw after storing it, found in the store and
842
847
  * counted as written, it is what landed, joined the same way, which for a
843
848
  * sharded body can be its first shards alone. A store that can't be read
844
- * back (storedCopiesOf logs it) leaves a sharded body as given. */
849
+ * back (storedCopiesOf logs it) leaves a sharded body as given. `parts` is
850
+ * how many store messages hold it, as read back: a sharded body's shards,
851
+ * consecutive from `id`, and 1 where it couldn't be read back. */
845
852
  interface DrainedWrite {
846
853
  id: MessageId;
847
854
  content: ContentBlock[];
855
+ parts: number;
848
856
  }
849
857
 
850
858
  /** What later compiles render from the store for the copies of one write
@@ -860,12 +868,33 @@ function asCompiled(landed: ReadonlyArray<{ content: ContentBlock[] }>): Content
860
868
  const text = blocks.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text).join('');
861
869
  return [...blocks.filter((b) => b.type !== 'text'), { type: 'text', text } as ContentBlock];
862
870
  }
871
+
872
+ /** A message's size as a notice gives it: its text's characters and its
873
+ * images, each left out when there are none, so empty when it has
874
+ * neither. */
875
+ function contentSize(content: readonly ContentBlock[]): string {
876
+ let chars = 0;
877
+ let images = 0;
878
+ for (const b of content) {
879
+ if (b.type === 'text') chars += b.text.length;
880
+ else if (b.type === 'image' || b.type === 'generated_image') images++;
881
+ }
882
+ const parts: string[] = [];
883
+ if (chars > 0) parts.push(`${chars.toLocaleString('en-US')} character${chars === 1 ? '' : 's'}`);
884
+ if (images > 0) parts.push(`${images} image${images === 1 ? '' : 's'}`);
885
+ return parts.join(' and ');
886
+ }
887
+
863
888
  /** One stored copy of a deferred write, as the drain reads it back: a
864
889
  * sharded body's copies carry its `bodyGroupId`. */
865
890
  type StoredCopy = { id: MessageId; content: ContentBlock[]; bodyGroupId?: string };
866
891
  const storedCopy = (m: { id: MessageId; content: ContentBlock[]; bodyGroupId?: string }): StoredCopy =>
867
892
  ({ id: m.id, content: m.content, ...(m.bodyGroupId !== undefined ? { bodyGroupId: m.bodyGroupId } : {}) });
868
893
 
894
+ /** A tool result's spill file as writeToolResultFile left it: its path, with
895
+ * an error if the write failed, or null with no writable workspace. */
896
+ type SpillWrite = { path: string; error?: string; branchLeft?: true } | null;
897
+
869
898
  /** A released agent's window as its pass to the primary reads it
870
899
  * (AgentFramework.releasedWindow). */
871
900
  type ReleasedWindow = {
@@ -1112,9 +1141,10 @@ type SpeechPublished = (to: PublishDestination, text: string, messageId: string
1112
1141
 
1113
1142
  /**
1114
1143
  * How a round's prose is delivered: under which turn's routing, and how what
1115
- * it doesn't send is held. `notice` says when the notice for an envelope that
1116
- * bounces or is held back as a draft is presented: with this round's tool
1117
- * results ('now') or at the next boundary that presents messages ('later').
1144
+ * it doesn't send is held. `notice` says whether the notice for an envelope
1145
+ * that bounces or is held back as a draft is queued now, for this round's tool
1146
+ * boundary on a stream that presents injected messages ('now'), or not at all
1147
+ * ('later'), which leaves the drafts to be named as holdProseDrafts says.
1118
1148
  * `keepPrivate` is a skip_reply round: its envelopes all stay in the context,
1119
1149
  * and none is sent, bounced or held.
1120
1150
  */
@@ -2875,6 +2905,10 @@ export class AgentFramework {
2875
2905
  * ephemeral agents have isolated slots and are their own only readers.
2876
2906
  */
2877
2907
  private readonly sharedSlotAgents = new Set<Agent>();
2908
+ /** Rounds of tool calls, by their results array, that a save_recent_image
2909
+ * in the round counted by their share of the bound: such a round is
2910
+ * stored by its share whether or not it fits (buildStoredToolResultContent). */
2911
+ private readonly toolRoundsByShare = new WeakSet<CompletedToolCall[]>();
2878
2912
  /** The windows of forks and runs deregistered while the host was quiesced,
2879
2913
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
2880
2914
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -3612,6 +3646,8 @@ export class AgentFramework {
3612
3646
  },
3613
3647
  );
3614
3648
  framework.tuneOutCoordinator.resumeActiveEpochs();
3649
+ } else {
3650
+ framework.endTuneOutsWithoutCoordinator();
3615
3651
  }
3616
3652
 
3617
3653
  // Modules, agents, MCPL servers and the tune-out tool are all in place:
@@ -11529,6 +11565,10 @@ export class AgentFramework {
11529
11565
  // Speech happens only via speak_in_channel (its own marked voice).
11530
11566
  proseRouting: 'explicit',
11531
11567
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
11568
+ // No clock rows of its own: its merged view carries the residents'
11569
+ // clocks, and clocks it planned from its own rows alone would read the
11570
+ // time a second time beside them, or disagree with them.
11571
+ clock: undefined,
11532
11572
  };
11533
11573
  const agent = new Agent(agentConfig, contextManager, this.membrane);
11534
11574
  this.adoptToolResultGuard(agent);
@@ -11694,7 +11734,7 @@ export class AgentFramework {
11694
11734
  if (currentState.status === 'ready') {
11695
11735
  // Effective inline cap: agent_settings override → durable config →
11696
11736
  // house default, clamped to the strategy bound (see resolver).
11697
- const maxChars = this.resolveToolResultInlineCap(agent).cap;
11737
+ const { cap: maxChars, bound } = this.resolveToolResultInlineCap(agent);
11698
11738
 
11699
11739
  // Oversized results — successes AND errors — spill to a workspace
11700
11740
  // file (truncated preview + file reference) instead of being
@@ -11702,8 +11742,8 @@ export class AgentFramework {
11702
11742
  // history copy and the live wire copy below — the two must stay
11703
11743
  // byte-matched (the window stores what the membrane sends;
11704
11744
  // divergence breaks the compile prefix).
11705
- const { blocks: toolResultContent, spilled } =
11706
- await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
11745
+ const { blocks: toolResultContent, spilled, keptCap } =
11746
+ await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars, bound);
11707
11747
 
11708
11748
  // A spill writes to the workspace, and the turn can be stopped
11709
11749
  // meanwhile. A host's cancelStream, stop() and abortInference cancel
@@ -11759,7 +11799,7 @@ export class AgentFramework {
11759
11799
  const membraneResults = currentState.toolResults.map(tc =>
11760
11800
  this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
11761
11801
  );
11762
- agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, maxChars);
11802
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, keptCap);
11763
11803
 
11764
11804
  // Flush any messages that were deferred while this turn was in
11765
11805
  // flight: this agent's own entries, those addressed to it and, for
@@ -11772,14 +11812,21 @@ export class AgentFramework {
11772
11812
  // live turn sees them instead of the agent staying deaf until the
11773
11813
  // turn ends. The window stores them here, right after the
11774
11814
  // tool_result message, which is exactly where the membrane appends
11775
- // them in the live conversation — same participant, same content
11776
- // blocks, and neither carries the row's metadata — and compile()
11777
- // emits recent messages verbatim, so the next turn's compiled prefix
11778
- // byte-matches the live request (prompt-cache safe). A write that
11779
- // threw after it was stored, whose landing couldn't be read, is the
11780
- // exception: it's carried at the flush that finds it, after what was
11781
- // stored since, so there the store and the live request differ
11782
- // (writeDrainedInOrder).
11815
+ // them in the live conversation — same participant, and neither
11816
+ // carries the row's metadata — and each goes in as the next compile
11817
+ // will send it, which the strategy renders (renderForInjection): cut
11818
+ // by the per-message cap, a sharded body joined, and its images as
11819
+ // the image limits leave them at the newest position. So the next
11820
+ // turn's compiled prefix matches the live request (prompt-cache
11821
+ // safe), up to what came after them: a later message can still
11822
+ // take an earlier one past an image limit, as for any message, and
11823
+ // the next compile then strips that image where the live rounds
11824
+ // carried it. One the next compile won't send among its newest
11825
+ // isn't injected; a notice after the batch tells the agent of it
11826
+ // (recordLeftOutNotice). A write that threw after it was stored, whose landing
11827
+ // couldn't be read, is the exception: it's carried at the flush
11828
+ // that finds it, after what was stored since, so there the store and
11829
+ // the live request differ (writeDrainedInOrder).
11783
11830
  //
11784
11831
  // The queue is drained ONLY at the target agent's own boundary:
11785
11832
  // draining at another agent's boundary would store the messages
@@ -11814,22 +11861,30 @@ export class AgentFramework {
11814
11861
  // a held message does, rather than sitting in the window between
11815
11862
  // rounds the stream never showed it.
11816
11863
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
11817
- // The injected messages' ids, for a boundary that leaves them to the
11818
- // turn's next start as arrivals.
11819
- const injectedIds = new Map<object, MessageId>();
11864
+ // What this boundary stored for the agent to hear, in store order,
11865
+ // injected or left out, for a boundary that leaves it to the turn's
11866
+ // next start as arrivals: that start's compile sends it.
11867
+ const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[]; parts: number }> = [];
11820
11868
  if (currentState.supportsInjectedMessages !== false) {
11869
+ // A clock row due (storeDueClock) goes right after the round's
11870
+ // results, ahead of what is drained below, and joins the stream
11871
+ // the same way. A boundary that passed during the round can't
11872
+ // stand between a tool's call and its results, so this clock
11873
+ // reads the time it's written (clock-rows.ts).
11874
+ const clock = this.storeDueClock(agent, 'tool-boundary');
11821
11875
  const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
11822
11876
  // Stored in queue order, cut at the first that can't be stored: it
11823
11877
  // and what follows wait for the turn's end, and only what was
11824
11878
  // stored is injected (the round's results go on either way). One
11825
11879
  // the store refuses as malformed is dropped instead, and what
11826
- // follows goes on. A sharded body read back from its store is
11827
- // injected as later compiles render it, its shards joined, and a
11828
- // write that threw after storing, which counts as stored, as what
11829
- // landed of it (writeDrainedInOrder).
11880
+ // follows goes on. What was stored is injected as the next compile
11881
+ // will send it (renderForInjection). Where the strategy can't say,
11882
+ // a sharded body read back from its store is injected as later
11883
+ // compiles join it, and a write that threw after storing, which
11884
+ // counts as stored, as what landed of it (writeDrainedInOrder).
11830
11885
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) =>
11831
11886
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
11832
- for (const [msg, { id, content }] of stored) {
11887
+ for (const [msg, { id, content, parts }] of stored) {
11833
11888
  // Every other resident reads what this boundary stores; for its
11834
11889
  // own agent, the boundary weighs what it injects below.
11835
11890
  this.noteForOtherResidents(agent, msg.metadata, id);
@@ -11838,13 +11893,44 @@ export class AgentFramework {
11838
11893
  // compiled view; injecting it here would put in the live turn
11839
11894
  // what the next compile hides (#232 gap 5).
11840
11895
  if ((msg.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) continue;
11841
- const injection = {
11842
- participant: msg.participant,
11843
- content,
11844
- ...(msg.metadata ? { metadata: msg.metadata } : {}),
11845
- };
11846
- midTurnInjections.push(injection);
11847
- injectedIds.set(injection, id);
11896
+ heard.push({ msg, id, content, parts });
11897
+ }
11898
+ // Rendered in one call: a render prepares as a compile does, so
11899
+ // the batch shares one preparation. The image limits count from
11900
+ // the batch's oldest message to the newest, as a compile does.
11901
+ // The clock, stored ahead of them, renders first in the same batch
11902
+ // and is injected first, as the next compile sends it.
11903
+ const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
11904
+ const first = clock ? 1 : 0;
11905
+ if (clock) {
11906
+ const sent = rendered ? rendered[0]! : clock.content;
11907
+ if (sent !== null) {
11908
+ midTurnInjections.push({ participant: 'user', content: sent, metadata: clock.metadata });
11909
+ } else {
11910
+ console.error(`[clock] ${agent.name}: clock ${clock.id} left out of the live round, since the next compile won't send it among its newest`);
11911
+ }
11912
+ }
11913
+ const leftOut: typeof heard = [];
11914
+ heard.forEach((h, i) => {
11915
+ const sent = rendered ? rendered[i + first]! : h.content;
11916
+ // The next compile won't send it among its newest: injected, it
11917
+ // would put in the live turn what that compile sends elsewhere,
11918
+ // or not at all. It's under any consumed mark the boundary sets
11919
+ // (below), as anything stored here is.
11920
+ if (sent === null) { leftOut.push(h); return; }
11921
+ midTurnInjections.push({
11922
+ participant: h.msg.participant,
11923
+ content: sent,
11924
+ ...(h.msg.metadata ? { metadata: h.msg.metadata } : {}),
11925
+ });
11926
+ });
11927
+ if (leftOut.length > 0) {
11928
+ console.error(`[injection] ${agent.name}: left out of the live round, since the next compile won't send them among its newest: ${leftOut.map((h) => h.id).join(', ')}`);
11929
+ // The agent is told what it didn't hear, after the batch, as
11930
+ // the routing notice is: stored, so the next compile sends it
11931
+ // where the live round heard it.
11932
+ const notice = this.recordLeftOutNotice(agent, leftOut);
11933
+ if (notice) midTurnInjections.push(notice);
11848
11934
  }
11849
11935
  }
11850
11936
 
@@ -11982,10 +12068,11 @@ export class AgentFramework {
11982
12068
  }
11983
12069
  } else if (routesLocus && !shouldEndTurn) {
11984
12070
  // A boundary that can't re-pin now (over budget, or no live
11985
- // stream to tell) leaves its injections to the turn's next start,
11986
- // as arrivals: a budget restart, or a re-inference on these tool
11987
- // results.
11988
- for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj)!);
12071
+ // stream to tell) leaves what it stored for the agent to the
12072
+ // turn's next start, as arrivals: a budget restart, or a
12073
+ // re-inference on these tool results. That includes a message
12074
+ // left out of the injection, since that start compiles anew.
12075
+ for (const { msg, id } of heard) this.noteArrival(agent.name, msg.metadata, id);
11989
12076
  }
11990
12077
 
11991
12078
  if (shouldEndTurn) {
@@ -12088,6 +12175,18 @@ export class AgentFramework {
12088
12175
  // Under context-manager#160 such a write already gives the
12089
12176
  // replies stored after it stamps their requests didn't carry,
12090
12177
  // whatever the mark says; deferring those writers is the fix.
12178
+ // The mark also covers a message the boundary stored and left out
12179
+ // of the injection, since the next compile won't send it among its
12180
+ // newest: the notice telling the agent of it is injected, so a
12181
+ // boundary that leaves one out injects something, unless that
12182
+ // notice couldn't be stored. The message then counts as read
12183
+ // though no request carried it: a coalesced render's wake to read
12184
+ // it drops, and a release doesn't pass it to the primary. The
12185
+ // notice has told the agent it came, from whom, and how to read
12186
+ // it. Where the view leaves it out, no request will carry it.
12187
+ // Where the memory strategy places it before its recent window, a
12188
+ // batch bigger than that window, the next compile carries it, in
12189
+ // whatever turn something else starts.
12091
12190
  if (midTurnInjections.length > 0) {
12092
12191
  // Possibly shown, whatever the stream declares: the first-view
12093
12192
  // mark (CoalescerHost.isUnshown) counts a doubt as shown.
@@ -12416,6 +12515,11 @@ export class AgentFramework {
12416
12515
  if (!target) return undefined;
12417
12516
  if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
12418
12517
  metadata.triggered = event.triggerInference ?? false;
12518
+ // A fork's channel messages are stored past its deferral; a clock goes
12519
+ // ahead of one only while the fork has no turn alive (storeDueClock).
12520
+ if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
12521
+ this.storeDueClock(target, 'event');
12522
+ }
12419
12523
  const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content));
12420
12524
  // A correction may target an older engagement. Refresh only the
12421
12525
  // binding whose agent actually received the message.
@@ -12599,6 +12703,10 @@ export class AgentFramework {
12599
12703
  const trigger = decision.trigger && event.triggerInference !== false;
12600
12704
  messageMetadata.triggered = trigger;
12601
12705
 
12706
+ // As for a correction above: a clock ahead only while no turn is alive.
12707
+ if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
12708
+ this.storeDueClock(agent, 'event');
12709
+ }
12602
12710
  const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
12603
12711
  router.touch(event.channelId);
12604
12712
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
@@ -15423,6 +15531,102 @@ export class AgentFramework {
15423
15531
  return routing.engaged.has(m.channelId) ? m.channelId : undefined;
15424
15532
  }
15425
15533
 
15534
+ /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */
15535
+ private turnAlive(agent: Agent): boolean {
15536
+ return this.activeTurnTokens.has(agent.name)
15537
+ || this.pendingAssistantBlocks.has(agent.name)
15538
+ || this.activeStreams.has(agent.name);
15539
+ }
15540
+
15541
+ /**
15542
+ * Store the clock row due in `agent`'s window (clock-rows.ts), if the agent
15543
+ * has clock rows and a boundary has passed since the window's last clock,
15544
+ * or the window holds no clock row yet (planClock), and return it. The
15545
+ * caller places it, by calling this right before what the clock precedes
15546
+ * is written: an incoming event while no turn of the agent's is alive (or
15547
+ * one is assembling before its compile), a fresh turn's inputs before its
15548
+ * compile, or, mid-turn, the messages a tool boundary injects, where the
15549
+ * caller injects the clock first. Each is a place where a row is either in
15550
+ * the request that follows or before every request still to come, so the
15551
+ * window and the live stream keep matching.
15552
+ *
15553
+ * None while the host is quiesced (the window is frozen for surgery), and
15554
+ * none in the residents' shared slot while another of its readers (the
15555
+ * subconscious among them, through its merged view) has a turn alive: the
15556
+ * row would sit inside that turn without joining its stream. A clock that
15557
+ * can't be planned or stored is logged and skipped: it never holds up what
15558
+ * it would have preceded. One whose write threw after it landed is the
15559
+ * window's all the same, so it's returned, and a tool boundary injects it.
15560
+ */
15561
+ private storeDueClock(
15562
+ agent: Agent,
15563
+ occasion: 'event' | 'turn-start' | 'tool-boundary',
15564
+ ): { id: MessageId; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
15565
+ const config = agent.clock;
15566
+ if (!config || this.quiesced) return undefined;
15567
+ if (this.sharedSlotAgents.has(agent)) {
15568
+ for (const reader of this.sharedSlotAgents) {
15569
+ if (reader !== agent && this.turnAlive(reader)) return undefined;
15570
+ }
15571
+ }
15572
+ let planned: ReturnType<typeof planClock>;
15573
+ try {
15574
+ planned = planClock({ now: Date.now(), ...this.lastClockOf(agent.getContextManager()), config, zone: this.timeZone });
15575
+ } catch (err) {
15576
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}:`, err);
15577
+ return undefined;
15578
+ }
15579
+ if (!planned) return undefined;
15580
+ const metadata = { system: true, kind: CLOCK_ROW_KIND, clock: planned.stamp } as MessageMetadata;
15581
+ const before = this.storedCountOf(agent);
15582
+ let id: MessageId;
15583
+ try {
15584
+ id = agent.getContextManager().addMessage('user', planned.content, metadata);
15585
+ } catch (err) {
15586
+ // A write can throw after its row landed, as when context-manager's own
15587
+ // propagation of it fails: what landed is read back from where the
15588
+ // store stood before, as writeDrainedInOrder reads a drained write's.
15589
+ const at = planned.stamp.at;
15590
+ const landed = before === undefined
15591
+ ? undefined
15592
+ : this.storedSince(agent, before, (m) => clockStampOf(m.metadata)?.at === at, 'clock', 'the clock write');
15593
+ if (!landed || landed.length === 0) {
15594
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}${landed ? '' : ' (whether it landed is unknown)'}:`, err);
15595
+ return undefined;
15596
+ }
15597
+ console.error(`[clock] ${agent.name}: the clock row at ${occasion} landed though its write threw:`, err);
15598
+ id = landed[0]!.id;
15599
+ }
15600
+ this.emitTrace({ type: 'message:added', messageId: id, source: `clock:${occasion}` });
15601
+ return { id, content: planned.content, metadata };
15602
+ }
15603
+
15604
+ /** Rows a window's last-clock scan reads at a time. */
15605
+ private static readonly CLOCK_SCAN_ROWS = 256;
15606
+
15607
+ /** What the next clock is planned from: the window's newest row's time and
15608
+ * the time its last clock row reads, found by reading back from the
15609
+ * newest row, CLOCK_SCAN_ROWS at a time, until a clock row comes, however
15610
+ * far back it is. In the steady state the newest chunk holds it, unless
15611
+ * more rows than that have come since the last clock; a window with no
15612
+ * clock row is read whole once, and then takes one (planClock). */
15613
+ private lastClockOf(cm: ContextManager): { newestRowAt: number | undefined; lastClockAt: number | undefined } {
15614
+ let newestRowAt: number | undefined;
15615
+ for (let end = cm.getMessageCount(); end > 0;) {
15616
+ const span = Math.min(end, AgentFramework.CLOCK_SCAN_ROWS);
15617
+ const { messages } = cm.getMessageWindow(end - span, span, { resolveBlobs: false });
15618
+ if (newestRowAt === undefined && messages.length > 0) {
15619
+ newestRowAt = new Date(messages[messages.length - 1]!.timestamp).getTime();
15620
+ }
15621
+ for (let i = messages.length - 1; i >= 0; i--) {
15622
+ const stamp = clockStampOf(messages[i]!.metadata);
15623
+ if (stamp) return { newestRowAt, lastClockAt: stamp.at };
15624
+ }
15625
+ end -= span;
15626
+ }
15627
+ return { newestRowAt, lastClockAt: undefined };
15628
+ }
15629
+
15426
15630
  /**
15427
15631
  * Weigh a message that no tool boundary of the agent's open logical turn
15428
15632
  * will weigh: one that would re-pin the locus becomes one of the turn's
@@ -16971,6 +17175,16 @@ export class AgentFramework {
16971
17175
  // turn, and flushing there would insert messages between its rounds.
16972
17176
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
16973
17177
  // products — undoing the turn must not destroy them.
17178
+ // A clock row due (storeDueClock) goes ahead of the turn's inputs, so a
17179
+ // turn starts with a current clock, a silent heartbeat's tick (which
17180
+ // stores no row of its own) included, unless storeDueClock writes none:
17181
+ // while the host is quiesced, while another reader of the residents'
17182
+ // shared slot has a turn alive, or when its write fails without landing.
17183
+ // Then a later event, turn start or tool boundary takes it. Fresh turns
17184
+ // only, as the flush below: a turn that resumes continues its logical
17185
+ // turn, which took its clock at its start or at a tool boundary that
17186
+ // could carry one.
17187
+ if (attempt === 0 && !resumesTurn) this.storeDueClock(agent, 'turn-start');
16974
17188
  if (
16975
17189
  attempt === 0 &&
16976
17190
  !resumesTurn &&
@@ -18508,15 +18722,16 @@ export class AgentFramework {
18508
18722
  // one door a giant blob still walks through).
18509
18723
  const readyState = agent.state as AgentState;
18510
18724
  if (readyState.status === 'ready') {
18511
- const cap = this.resolveToolResultInlineCap(agent).cap;
18512
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
18725
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
18726
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(
18513
18727
  agent.name,
18514
18728
  readyState.toolResults,
18515
18729
  cap,
18730
+ bound,
18516
18731
  );
18517
18732
  agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
18518
18733
  this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults,
18519
- this.silentTurnRowMetadata(agent.name), cap);
18734
+ this.silentTurnRowMetadata(agent.name), keptCap);
18520
18735
  // The response is already complete: this batch is never
18521
18736
  // submitted in this turn, so it cannot be refused. Admit it.
18522
18737
  agent.toolResultGuard.settleTurnEnded();
@@ -19826,10 +20041,12 @@ export class AgentFramework {
19826
20041
  // token (as during a real tool call) and lands AFTER the pair, never
19827
20042
  // between tool_use and its tool_result. The two addMessage calls
19828
20043
  // below are synchronous and adjacent — nothing can interleave.
20044
+ const { bound } = this.resolveToolResultInlineCap(agent);
19829
20045
  const { blocks } = await this.buildStoredToolResultContent(
19830
20046
  agentName,
19831
20047
  [{ id: toolUseId, name: toolName, input, result, durationMs }],
19832
20048
  call.resultMaxChars,
20049
+ bound,
19833
20050
  );
19834
20051
  const toolUse: ContentBlock[] = [
19835
20052
  { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
@@ -20291,10 +20508,12 @@ export class AgentFramework {
20291
20508
  // An agent that has gone gets nothing, its drop logged once (noticeTargetFor).
20292
20509
  if (!this.noticeTargetFor(agentName, `script ${scriptId} wake`)) throw new Error(`agent ${agentName} has no context to wake`);
20293
20510
  const agent = this.agents.get(agentName);
20294
- const maxChars = agent
20295
- ? this.resolveToolResultInlineCap(agent).cap
20296
- : DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
20297
- const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`);
20511
+ // A reader's strategy cuts the wake's whole message, so its spill fits the
20512
+ // readers' bound with its notice.
20513
+ const { cap: maxChars, bound } = agent
20514
+ ? this.resolveToolResultInlineCap(agent)
20515
+ : { cap: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, bound: undefined };
20516
+ const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`, null, bound);
20298
20517
  // The spill can take a while: the agent may have gone since, or the host begun to stop.
20299
20518
  if (this.stopHasBegun()) throw new Error('this host is stopping');
20300
20519
  const target = this.noticeTargetFor(agentName, `script ${scriptId} wake`);
@@ -20344,15 +20563,38 @@ export class AgentFramework {
20344
20563
  agentName: string,
20345
20564
  toolResults: CompletedToolCall[],
20346
20565
  maxChars: number | undefined,
20566
+ /** The readers' smallest per-message bound (resolveToolResultInlineCap). */
20567
+ bound: number | undefined = undefined,
20347
20568
  ): Promise<{
20348
20569
  blocks: ContentBlock[];
20349
20570
  spilled: Map<string, { text: string; filePath: string | null }>;
20571
+ /** Where a withheld stub's kept fields are cut: the share's keptCap
20572
+ * (toolResultShare), which is the cap where there's no bound. */
20573
+ keptCap: number | undefined;
20350
20574
  }> {
20351
20575
  const spilled = new Map<string, { text: string; filePath: string | null }>();
20352
20576
  const dateLabel = new Date().toISOString().slice(0, 10);
20577
+ // The results are stored as one message, whose bound a reader's
20578
+ // strategy spends across them in order. Each is stored as it would be
20579
+ // alone, unless together they pass the bound, or a save in the round
20580
+ // counted the round by its share (toolRoundsByShare): then each is
20581
+ // stored under its share (toolResultShare). A result carrying an image
20582
+ // keeps its live copy cut at the cap; any other's live copy is its stored
20583
+ // text.
20584
+ const alone = new Map<string, { text: string; shown: ShownRanges | null; stored: { text: string; filePath: string | null; written?: SpillWrite } }>();
20353
20585
  for (const tc of toolResults) {
20354
20586
  const { text, shown } = this.storedToolResultText(agentName, tc, maxChars);
20355
- spilled.set(tc.id, await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown));
20587
+ alone.set(tc.id, { text, shown, stored: await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown, bound) });
20588
+ }
20589
+ const share = maxChars === undefined ? undefined : toolResultShare(maxChars, bound, toolResults.length);
20590
+ const total = [...alone.values()].reduce((sum, { stored }) => sum + stored.text.length, 0);
20591
+ const byShare = share?.bound !== undefined && toolResults.length > 1
20592
+ && (this.toolRoundsByShare.has(toolResults) || total > bound!);
20593
+ for (const tc of toolResults) {
20594
+ const { text, shown, stored } = alone.get(tc.id)!;
20595
+ spilled.set(tc.id, byShare
20596
+ ? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written)
20597
+ : stored);
20356
20598
  }
20357
20599
  const blocks: ContentBlock[] = toolResults.map(tc => ({
20358
20600
  type: 'tool_result' as const,
@@ -20363,7 +20605,10 @@ export class AgentFramework {
20363
20605
  content: spilled.get(tc.id)!.text,
20364
20606
  isError: tc.result.isError,
20365
20607
  }));
20366
- return { blocks, spilled };
20608
+ // A withheld round's stubs carry the guard's notice and place beside the
20609
+ // fields they keep, so under a bound their fields are cut within the
20610
+ // share, whether or not the round's results were stored by it.
20611
+ return { blocks, spilled, keptCap: share?.keptCap ?? maxChars };
20367
20612
  }
20368
20613
 
20369
20614
  /**
@@ -20492,7 +20737,7 @@ export class AgentFramework {
20492
20737
  label: string,
20493
20738
  content: string,
20494
20739
  branch?: string,
20495
- ): Promise<{ path: string; error?: string; branchLeft?: true } | null> {
20740
+ ): Promise<SpillWrite> {
20496
20741
  const place = this.toolResultFilePlace(label);
20497
20742
  if (!place) return null;
20498
20743
  const { workspace, path, safeLabel } = place;
@@ -20536,16 +20781,18 @@ export class AgentFramework {
20536
20781
  }
20537
20782
 
20538
20783
  /** How to read a tool result's file back a page at a time, with each page
20539
- * sized to come back within `cap`. */
20540
- private spillPageAdvice(path: string, cap: number): string {
20784
+ * sized to come back within `cap`, and, where `canRaise`, that raising
20785
+ * the inline cap would show more: where a cap at the readers' bound would
20786
+ * keep a longer head, or the whole result. */
20787
+ private spillPageAdvice(path: string, cap: number, canRaise: boolean): string {
20541
20788
  // JSON serialization can expand each read-back code unit to six
20542
20789
  // chars (\\u0000). Leave room for the page's metadata and a possible
20543
20790
  // extra code unit at a surrogate-pair boundary.
20544
20791
  const pageOverhead = JSON.stringify(path).length + 256;
20545
20792
  const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
20546
20793
  return `Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
20547
- + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
20548
- + 'agent_settings update tool_result_inline_max_chars.';
20794
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars.'
20795
+ + (canRaise ? ' Or raise the inline cap via agent_settings update tool_result_inline_max_chars.' : '');
20549
20796
  }
20550
20797
 
20551
20798
  private async spillOrTruncate(
@@ -20555,7 +20802,14 @@ export class AgentFramework {
20555
20802
  /** What the live copy showed beside this text (storedToolResultText);
20556
20803
  * null when the live copy was this text. */
20557
20804
  shown: ShownRanges | null = null,
20558
- ): Promise<{ text: string; filePath: string | null }> {
20805
+ /** The most a reader's strategy keeps of this copy: the readers'
20806
+ * smallest per-message bound, or the result's share of it
20807
+ * (buildStoredToolResultContent). */
20808
+ bound: number | undefined = undefined,
20809
+ /** The spill file an earlier cut of this text wrote: reused, not written
20810
+ * again. Undefined when none was cut. */
20811
+ written?: SpillWrite,
20812
+ ): Promise<{ text: string; filePath: string | null; written?: SpillWrite }> {
20559
20813
  if (!cap || content.length <= cap) return { text: content, filePath: null };
20560
20814
  // The stored text is the only place save_recent_image discovers tool
20561
20815
  // images. When the live wire delivered them beside the text, whatever
@@ -20563,20 +20817,24 @@ export class AgentFramework {
20563
20817
  // saw that the index would silently slide past (issue #104), so the
20564
20818
  // newest slots past the cut that the live copy showed are re-appended
20565
20819
  // after the notice. Otherwise the live copy is this text, and nothing
20566
- // past the cut is kept (#403).
20567
- const cut = cutStoredToolResult(content, cap, shown);
20820
+ // past the cut is kept (#403). The stored copy, notice and kept slots
20821
+ // included, fits `bound`, the most a reader's strategy keeps of it, so
20822
+ // no reader's cut takes the notice (cutStoredToolResultToFit), and the
20823
+ // spill notices name the head they kept.
20824
+ const cut = cutStoredToolResultToFit(content, cap, shown, bound);
20568
20825
  const { head } = cut;
20569
20826
  const kept = formatKeptImageSlots(cut);
20570
20827
 
20571
- const written = await this.writeToolResultFile(label, content);
20828
+ if (written === undefined) written = await this.writeToolResultFile(label, content);
20572
20829
  if (written && written.error === undefined) {
20573
20830
  const path = written.path;
20574
20831
  return {
20575
20832
  text: head
20576
20833
  + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
20577
- + `. ${this.spillPageAdvice(path, cap)}]`
20834
+ + `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
20578
20835
  + kept,
20579
20836
  filePath: path,
20837
+ written,
20580
20838
  };
20581
20839
  }
20582
20840
  if (written) {
@@ -20589,6 +20847,7 @@ export class AgentFramework {
20589
20847
  + ` (${written.error}); content over the cap was not retained]`
20590
20848
  + kept,
20591
20849
  filePath: null,
20850
+ written,
20592
20851
  };
20593
20852
  }
20594
20853
  return {
@@ -20596,6 +20855,7 @@ export class AgentFramework {
20596
20855
  + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]'
20597
20856
  + kept,
20598
20857
  filePath: null,
20858
+ written,
20599
20859
  };
20600
20860
  }
20601
20861
 
@@ -20899,8 +21159,10 @@ export class AgentFramework {
20899
21159
  * the array is text-only (let JSON path handle it; saves a code path).
20900
21160
  * `maxChars`, when provided, caps each accompanying text block so an image
20901
21161
  * inlined alongside an enormous text payload can't blow the context. A
20902
- * block is cut as the stored copy is (toolResultCutAt), never inside a
20903
- * slot, so the stored copy can keep exactly the slots this showed (#403).
21162
+ * block is cut at the cap as the stored copy's cut at that cap is
21163
+ * (toolResultCutAt), never inside a slot, so the stored copy can keep
21164
+ * exactly the slots this showed (#403); under a readers' bound the stored
21165
+ * copy's cut can come earlier (cutStoredToolResultToFit).
20904
21166
  */
20905
21167
  private tryNativeToolResultContent(
20906
21168
  data: unknown,
@@ -20977,12 +21239,21 @@ export class AgentFramework {
20977
21239
  * (Sol's #94 ruling: a durable preference must not be a durable path for
20978
21240
  * one tool result to exceed maxMessageTokens; the way to see the whole
20979
21241
  * result is the spill file, not an over-bound blob in live context).
21242
+ * An ordinary resident's results land in the shared slot, which each of
21243
+ * its readers compiles under its own strategy, so the bound is the
21244
+ * smallest of theirs, as the tune-out dump's is (residentMessageChars); a
21245
+ * fork's or an ephemeral agent's store has itself as its only reader.
20980
21246
  */
20981
21247
  private resolveToolResultInlineCap(agent: Agent): {
20982
21248
  cap: number;
20983
21249
  source: 'agent-settings-override' | 'framework-config' | 'default';
20984
- /** Set when the desired value was reduced to the strategy bound. */
20985
- clampedBy: 'strategy-bound' | null;
21250
+ /** Set when the desired value was reduced to a bound: the agent's own
21251
+ * strategy's, or a smaller one of another reader of its slot. */
21252
+ clampedBy: 'strategy-bound' | 'reader-bound' | null;
21253
+ /** The smallest per-message bound among the readers of the agent's
21254
+ * store, which a stored copy, notice included, must fit
21255
+ * (cutStoredToolResultToFit); undefined when no reader has one. */
21256
+ bound: number | undefined;
20986
21257
  } {
20987
21258
  const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
20988
21259
  const configured = this.toolResultInlineMaxCharsConfig;
@@ -20990,12 +21261,24 @@ export class AgentFramework {
20990
21261
  const source = override !== undefined
20991
21262
  ? 'agent-settings-override' as const
20992
21263
  : configured !== null ? 'framework-config' as const : 'default' as const;
20993
- const strategyBound = this.strategyDerivedToolResultChars(agent);
20994
- const clamped = strategyBound !== undefined && strategyBound < desired;
21264
+ let bound = this.strategyDerivedToolResultChars(agent);
21265
+ let byReader = false;
21266
+ if (this.isOrdinaryResident(agent)) {
21267
+ for (const reader of this.sharedSlotAgents) {
21268
+ if (reader === agent) continue;
21269
+ const readerBound = this.strategyDerivedToolResultChars(reader);
21270
+ if (readerBound !== undefined && (bound === undefined || readerBound < bound)) {
21271
+ bound = readerBound;
21272
+ byReader = true;
21273
+ }
21274
+ }
21275
+ }
21276
+ const clamped = bound !== undefined && bound < desired;
20995
21277
  return {
20996
- cap: clamped ? strategyBound : desired,
21278
+ cap: clamped ? bound! : desired,
20997
21279
  source,
20998
- clampedBy: clamped ? 'strategy-bound' : null,
21280
+ clampedBy: clamped ? (byReader ? 'reader-bound' : 'strategy-bound') : null,
21281
+ bound,
20999
21282
  };
21000
21283
  }
21001
21284
 
@@ -21004,8 +21287,9 @@ export class AgentFramework {
21004
21287
  * its caller whole. A call from a running script resolves to the script,
21005
21288
  * which takes any result that fits its memory (scriptResultMaxChars). Any
21006
21289
  * other call's result is stored in its agent's context under the agent's
21007
- * inline cap, and a call for an agent the framework doesn't hold gets the
21008
- * house default.
21290
+ * inline cap, unless its round's results together pass the readers' bound,
21291
+ * when each takes its share (buildStoredToolResultContent), and a call for
21292
+ * an agent the framework doesn't hold gets the house default.
21009
21293
  */
21010
21294
  private resultMaxCharsFor(agentName: string, callId: string): number {
21011
21295
  const ce = this.codeExecution;
@@ -21653,8 +21937,9 @@ export class AgentFramework {
21653
21937
  // messages flush at the next turn's START (before its compile — riding
21654
21938
  // their own wake), at the agent's next tool boundary (where they are
21655
21939
  // ALSO injected into the live stream — hear-while-acting — all but a
21656
- // message a tune-out holds from the residents), or in driveStream's
21657
- // finally when the turn ends.
21940
+ // message a tune-out holds from the residents, and one the next compile
21941
+ // won't send among its newest), or in driveStream's finally when the
21942
+ // turn ends.
21658
21943
  // Quiesce (issue #122): the window exists to run compression/refold
21659
21944
  // against a FROZEN context — module events and api message.send must not
21660
21945
  // append mid-surgery. Deferred here, flushed by resume(). tool_result
@@ -21722,6 +22007,12 @@ export class AgentFramework {
21722
22007
  return '' as MessageId; // Deferred — flushed at the target's next boundary
21723
22008
  }
21724
22009
 
22010
+ // A clock row due goes first (storeDueClock). A write that gets here is a
22011
+ // tool result (which takes none), or no turn of the agent's is alive, or
22012
+ // its turn is assembling before its compile, or it comes from stop()'s
22013
+ // drain, once every stream it could join has settled: the clock is in
22014
+ // whatever request comes next.
22015
+ if (takesClockBefore(participant, content, metadata, agent.name)) this.storeDueClock(agent, 'event');
21725
22016
  const stored = agent.getContextManager().addMessage(
21726
22017
  participant,
21727
22018
  content,
@@ -21805,6 +22096,74 @@ export class AgentFramework {
21805
22096
  if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
21806
22097
  }
21807
22098
 
22099
+ /**
22100
+ * What the next compile sends for each of `ids`, messages a tool boundary
22101
+ * has just stored, as the agent's strategy renders them as the newest of
22102
+ * its window (ContextManager.renderAsNewest): cut by the per-message cap,
22103
+ * a sharded body joined, and the image limits applied over the batch, so
22104
+ * the live stream hears what the next compile sends. An id comes back as
22105
+ * null where that compile won't send the message among its newest.
22106
+ * Undefined where the strategy has no such hook, or the render threw,
22107
+ * which is logged: the boundary then injects what it stored, as it did
22108
+ * before strategies could say.
22109
+ */
22110
+ private renderForInjection(agent: Agent, ids: MessageId[]): Array<ContentBlock[] | null> | undefined {
22111
+ if (ids.length === 0) return undefined;
22112
+ try {
22113
+ return agent.getContextManager().renderAsNewest(ids);
22114
+ } catch (error) {
22115
+ console.error(`[injection] ${agent.name}: couldn't render the messages a tool boundary stored as the next compile sends them, so they're injected as stored:`, error);
22116
+ return undefined;
22117
+ }
22118
+ }
22119
+
22120
+ /**
22121
+ * Tell the agent of the messages a tool boundary stored and left out of
22122
+ * its injection, since the next compile won't send them among its newest:
22123
+ * who sent each and its size, and how to read it, `history--extract`
22124
+ * around it where the agent is shown that tool, else its id. None of a
22125
+ * message's words: the line stands in for a message the live round
22126
+ * doesn't carry. A system line, so it's no conversation: it neither
22127
+ * re-pins the turn nor clears its send suppression. It names the agent
22128
+ * it's for, since every resident sharing the slot reads it too. Returns
22129
+ * the notice as stored, for the boundary to inject after the batch, or
22130
+ * undefined when it couldn't be stored, which is logged.
22131
+ */
22132
+ private recordLeftOutNotice(
22133
+ agent: Agent,
22134
+ leftOut: ReadonlyArray<{ msg: { participant: string }; id: MessageId; content: ContentBlock[]; parts: number }>,
22135
+ ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
22136
+ const canExtract = this.advertisedToolsForAgent(agent.name).some((t) => t.name === 'history--extract');
22137
+ const one = leftOut.length === 1;
22138
+ const lines = leftOut.map(({ msg, id, content, parts }) => {
22139
+ const size = contentSize(content);
22140
+ // A sharded body is that many store messages, consecutive from its
22141
+ // first, which extract reads around it one by one. (Its "after" stops
22142
+ // at 100, so a body of more parts, over 300,000 tokens at the memory
22143
+ // strategy's default shard size, is read only that far.)
22144
+ const read = canExtract
22145
+ ? `history--extract with aroundId "${id}", before 0 and after ${parts - 1} reads it` +
22146
+ `${parts > 1 ? `, stored as ${parts} parts` : ''}.`
22147
+ : `Its id is ${id}${parts > 1 ? `, the first of the ${parts} parts it's stored as` : ''}.`;
22148
+ return `- From ${msg.participant}${size ? `, ${size}` : ''}. ${read}`;
22149
+ });
22150
+ const content: ContentBlock[] = [{
22151
+ type: 'text',
22152
+ text: `[not shown] For ${agent.name}: ${one ? 'A message' : `${leftOut.length} messages`} arrived during your turn that ` +
22153
+ `${one ? "isn't" : "aren't"} shown here, since your context doesn't keep ${one ? 'it' : 'them'} among ` +
22154
+ `your newest messages:\n${lines.join('\n')}`,
22155
+ }];
22156
+ const metadata = { system: true, kind: 'left-out-notice' } as MessageMetadata;
22157
+ try {
22158
+ const id = agent.getContextManager().addMessage('user', content, metadata);
22159
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'left-out-notice' });
22160
+ return { participant: 'user', content, metadata };
22161
+ } catch (err) {
22162
+ console.error(`[injection] ${agent.name}: couldn't store the notice of messages left out of the live round:`, err);
22163
+ return undefined;
22164
+ }
22165
+ }
22166
+
21808
22167
  /**
21809
22168
  * Write drained entries in queue (seq) order, whatever their targets:
21810
22169
  * residents share one message slot, so the order entries arrived in is the
@@ -21927,7 +22286,7 @@ export class AgentFramework {
21927
22286
  continue;
21928
22287
  }
21929
22288
  if (found.length > 0) {
21930
- written.set(msg, { id: found[0]!.id, content: asCompiled(found) });
22289
+ written.set(msg, { id: found[0]!.id, content: asCompiled(found), parts: found.length });
21931
22290
  this.logDeferredWriteError(msg, where, undefined, { count: found.length, sharded: found[0]!.bodyGroupId !== undefined, foundLater: true });
21932
22291
  onLanded?.(msg, found[0]!.id);
21933
22292
  continue;
@@ -21944,14 +22303,14 @@ export class AgentFramework {
21944
22303
  const copies = agent && before !== undefined && (this.storedCountOf(agent) ?? before) - before > 1
21945
22304
  ? this.storedCopiesOf(agent, before, msg.id) ?? []
21946
22305
  : [];
21947
- written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content });
22306
+ written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content, parts: Math.max(1, copies.length) });
21948
22307
  } catch (error) {
21949
22308
  // What landed, read back from where the store stood before the
21950
22309
  // write; undefined when the store couldn't say, before the write or
21951
22310
  // after it. With no agent there's no store it could have landed in.
21952
22311
  const landed = !agent ? [] : before === undefined ? undefined : this.storedCopiesOf(agent, before, msg.id);
21953
22312
  if (landed && landed.length > 0) {
21954
- written.set(msg, { id: landed[0]!.id, content: asCompiled(landed) });
22313
+ written.set(msg, { id: landed[0]!.id, content: asCompiled(landed), parts: landed.length });
21955
22314
  this.logDeferredWriteError(msg, where, error, { count: landed.length, sharded: landed[0]!.bodyGroupId !== undefined });
21956
22315
  onLanded?.(msg, landed[0]!.id);
21957
22316
  continue;
@@ -21992,13 +22351,26 @@ export class AgentFramework {
21992
22351
  * `bodyGroupId`. Undefined when the store can't be read back, which is
21993
22352
  * logged: not the same as nothing found. */
21994
22353
  private storedCopiesOf(agent: Agent, from: number, deferredWriteId: string): StoredCopy[] | undefined {
22354
+ return this.storedSince(
22355
+ agent, from, (m) => m.metadata?.deferredWriteId === deferredWriteId, 'deferral', `deferred write ${deferredWriteId}`);
22356
+ }
22357
+
22358
+ /** The messages `match` picks among those the agent's store gained from
22359
+ * position `from` on, in store order, with their blobs resolved. Undefined
22360
+ * when the store can't be read back, which is logged under `tag`, naming
22361
+ * `what` was written: not the same as nothing found. */
22362
+ private storedSince(
22363
+ agent: Agent,
22364
+ from: number,
22365
+ match: (m: StoredMessage) => boolean,
22366
+ tag: string,
22367
+ what: string,
22368
+ ): StoredCopy[] | undefined {
21995
22369
  try {
21996
22370
  const cm = agent.getContextManager();
21997
- return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages
21998
- .filter((m) => m.metadata?.deferredWriteId === deferredWriteId)
21999
- .map(storedCopy);
22371
+ return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages.filter(match).map(storedCopy);
22000
22372
  } catch (error) {
22001
- console.error(`[deferral] could not read back what deferred write ${deferredWriteId} stored in ${agent.name}'s store:`, error);
22373
+ console.error(`[${tag}] could not read back what ${what} stored in ${agent.name}'s store:`, error);
22002
22374
  return undefined;
22003
22375
  }
22004
22376
  }
@@ -25185,6 +25557,9 @@ export class AgentFramework {
25185
25557
  // configured servers can still deploy its first one at runtime.
25186
25558
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
25187
25559
  await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
25560
+ // The lifecycle log's replay restored any tune-out on record, and no
25561
+ // coordinator is made here to carry one.
25562
+ this.endTuneOutsWithoutCoordinator();
25188
25563
  }
25189
25564
 
25190
25565
  const collision = this.mcplPrefixCollision(config, undefined, claim);
@@ -27050,6 +27425,47 @@ export class AgentFramework {
27050
27425
  };
27051
27426
  }
27052
27427
 
27428
+ /**
27429
+ * A tune-out on record where this host has no tune-out coordinator: a
27430
+ * start with no subconscious makes none, and neither does a channel
27431
+ * subsystem brought up after start by connectMcplServer. Nothing then
27432
+ * diverts the channel's traffic or ever cancels its tune-out, tune_out
27433
+ * isn't offered, and channel_open, channel_close and channel_decline
27434
+ * refuse a tuned-out channel, so the channel would stay held by a decision
27435
+ * nothing here can change. Each one ends here instead, its channel decided
27436
+ * open again as a cancel leaves it, under the tune-out's own source, so an
27437
+ * agent's tune-out keeps the agent's provenance (the housekeeping guard
27438
+ * reads it). A log line names the channel and says how many of the
27439
+ * messages it diverted are held here, stored or still queued to be
27440
+ * written, which nothing will now deliver as its backlog.
27441
+ */
27442
+ private endTuneOutsWithoutCoordinator(): void {
27443
+ const registry = this.channelRegistry;
27444
+ if (!registry || this.tuneOutCoordinator) return;
27445
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
27446
+ const ofEpoch = (epochId: string) => (m: { metadata?: unknown }): boolean =>
27447
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
27448
+ for (const { serverId, channelId, params, source } of registry.tuneOutsOnRecord()) {
27449
+ const diverted = typeof params.epochId !== 'string' ? 0
27450
+ : (primary?.getContextManager().getAllMessages() ?? []).filter(ofEpoch(params.epochId)).length +
27451
+ this.deferredMessages.filter((m) => (m.forAgent ?? this.primaryAgentName) === this.primaryAgentName)
27452
+ .filter(ofEpoch(params.epochId)).length;
27453
+ const held = diverted === 0 ? ''
27454
+ : diverted === 1 ? ' The 1 message it diverted stays here, and nothing will deliver it as its backlog.'
27455
+ : ` The ${diverted} messages it diverted stay here, and nothing will deliver them as its backlog.`;
27456
+ try {
27457
+ registry.cancelTuneOut(serverId, channelId, 'open', source);
27458
+ } catch (err) {
27459
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server is tuned out, and nothing on this host watches ` +
27460
+ `or cancels tune-outs, but ending this one failed, so it stays recorded until a start that can end it: ` +
27461
+ `${err instanceof Error ? err.message : String(err)}.${held}`);
27462
+ continue;
27463
+ }
27464
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server was tuned out, and nothing on this host watches ` +
27465
+ `or cancels tune-outs, so the tune-out ends here and the channel is decided open again.${held}`);
27466
+ }
27467
+ }
27468
+
27053
27469
  /**
27054
27470
  * Build BeforeInferenceParams from agent state and trigger context.
27055
27471
  */
@@ -27924,11 +28340,24 @@ export class AgentFramework {
27924
28340
  // carries the same ref.
27925
28341
  const state = agent.state;
27926
28342
  if (state.status === 'waiting_for_tools') {
27927
- const cap = this.resolveToolResultInlineCap(agent).cap;
28343
+ // The commit stores a round as each result would be alone, or by
28344
+ // its share where they pass the bound together, which a sibling
28345
+ // still running can decide. Where the two ways differ for a result
28346
+ // counted here, the round is marked to be stored by its share
28347
+ // (toolRoundsByShare), so the count holds either way: a result
28348
+ // counted before the mark is the same both ways. The share is of
28349
+ // the round's calls, finished or not.
28350
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
28351
+ const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
27928
28352
  for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
27929
28353
  const tc = state.completed[i]!;
27930
28354
  const { text, shown } = this.storedToolResultText(agentName, tc, cap);
27931
- considerToolResultImages(tc.id, tc.name, storedImageSlots(text, cap, shown), 0);
28355
+ const alone = storedImageSlots(text, cap, shown, bound);
28356
+ const byShare = storedImageSlots(text, share.cap, shown, share.bound);
28357
+ if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j]!.text)) {
28358
+ this.toolRoundsByShare.add(state.completed);
28359
+ }
28360
+ considerToolResultImages(tc.id, tc.name, this.toolRoundsByShare.has(state.completed) ? byShare : alone, 0);
27932
28361
  }
27933
28362
  }
27934
28363