@animalabs/agent-framework 0.24.0 → 0.25.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.
Files changed (83) 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 +27 -8
  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 +98 -6
  10. package/dist/src/framework.d.ts.map +1 -1
  11. package/dist/src/framework.js +514 -80
  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/mcpl/references.d.ts +4 -1
  23. package/dist/src/mcpl/references.d.ts.map +1 -1
  24. package/dist/src/mcpl/references.js +5 -2
  25. package/dist/src/mcpl/references.js.map +1 -1
  26. package/dist/src/modules/workspace/index.d.ts +9 -1
  27. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  28. package/dist/src/modules/workspace/index.js +118 -28
  29. package/dist/src/modules/workspace/index.js.map +1 -1
  30. package/dist/src/modules/workspace/types.d.ts +6 -2
  31. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  32. package/dist/src/modules/workspace/types.js.map +1 -1
  33. package/dist/src/text-as-stored.d.ts +31 -0
  34. package/dist/src/text-as-stored.d.ts.map +1 -0
  35. package/dist/src/text-as-stored.js +70 -0
  36. package/dist/src/text-as-stored.js.map +1 -0
  37. package/dist/src/timezone.d.ts +4 -2
  38. package/dist/src/timezone.d.ts.map +1 -1
  39. package/dist/src/timezone.js +4 -2
  40. package/dist/src/timezone.js.map +1 -1
  41. package/dist/src/tool-image-ledger.d.ts +46 -7
  42. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  43. package/dist/src/tool-image-ledger.js +60 -7
  44. package/dist/src/tool-image-ledger.js.map +1 -1
  45. package/dist/src/tool-presentation.d.ts.map +1 -1
  46. package/dist/src/tool-presentation.js +9 -1
  47. package/dist/src/tool-presentation.js.map +1 -1
  48. package/dist/src/tool-result-guard.d.ts +10 -8
  49. package/dist/src/tool-result-guard.d.ts.map +1 -1
  50. package/dist/src/tool-result-guard.js +12 -10
  51. package/dist/src/tool-result-guard.js.map +1 -1
  52. package/dist/src/tool-result-history.d.ts +17 -6
  53. package/dist/src/tool-result-history.d.ts.map +1 -1
  54. package/dist/src/tool-result-history.js +35 -10
  55. package/dist/src/tool-result-history.js.map +1 -1
  56. package/dist/src/tune-out/coordinator.js +3 -3
  57. package/dist/src/tune-out/coordinator.js.map +1 -1
  58. package/dist/src/types/agent.d.ts +27 -0
  59. package/dist/src/types/agent.d.ts.map +1 -1
  60. package/dist/src/types/events.d.ts +13 -3
  61. package/dist/src/types/events.d.ts.map +1 -1
  62. package/dist/src/types/framework.d.ts +7 -4
  63. package/dist/src/types/framework.d.ts.map +1 -1
  64. package/package.json +3 -3
  65. package/src/agent.ts +27 -8
  66. package/src/clock-rows.ts +302 -0
  67. package/src/framework.ts +538 -85
  68. package/src/index.ts +2 -0
  69. package/src/mcpl/channel-registry.ts +54 -8
  70. package/src/mcpl/protocol-family.ts +1 -1
  71. package/src/mcpl/references.ts +5 -2
  72. package/src/modules/workspace/index.ts +121 -29
  73. package/src/modules/workspace/types.ts +6 -2
  74. package/src/text-as-stored.ts +73 -0
  75. package/src/timezone.ts +4 -2
  76. package/src/tool-image-ledger.ts +62 -8
  77. package/src/tool-presentation.ts +8 -1
  78. package/src/tool-result-guard.ts +12 -10
  79. package/src/tool-result-history.ts +35 -9
  80. package/src/tune-out/coordinator.ts +3 -3
  81. package/src/types/agent.ts +28 -0
  82. package/src/types/events.ts +13 -3
  83. 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';
@@ -156,6 +157,7 @@ import { bounded, renderList, renderReturn, VALUE_CHARS } from './code-execution
156
157
  import { splitProseRuns, withoutRoundsProse } from './prose-segments.js';
157
158
  import { cumulativeDelta } from './usage-accounting.js';
158
159
  import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
160
+ import { contentAsStored, nameAsStored } from './text-as-stored.js';
159
161
 
160
162
  /** An MCPL tool call refused because its server isn't in the registry: one
161
163
  * closed for good, one removed, or one whose connect failed without
@@ -413,9 +415,12 @@ function malformedResponseContent(response: EventResponse): { where: string; pla
413
415
  * mid-turn message's text at chars/4 and its other blocks about as
414
416
  * context-manager's store prices them (injectedBlockTokens), and a small
415
417
  * 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
418
+ * message rides into the next round as the boundary injects it, which is as
419
+ * the strategy renders it for the next compile, cut and with its images
420
+ * limited (renderForInjection), or its stored content where the strategy
421
+ * can't say (provideToolResults' injectedMessages). Its XML tool path declares that it
422
+ * can't carry injected messages, so the tool boundary injects none there and
423
+ * that term is zero. Deliberately a
419
424
  * ceiling-ish heuristic, but for a mid-turn message's media at the store's
420
425
  * flat prices — the cost of overestimating is one early recompile;
421
426
  * the cost of underestimating is the 400 this projection exists to prevent.
@@ -693,6 +698,7 @@ import type {
693
698
  } from './mcpl/types.js';
694
699
  import type { ContextInjection } from '@animalabs/context-manager';
695
700
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
701
+ import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
696
702
  import { firstFreeBranchName } from './branch-names.js';
697
703
  import {
698
704
  DEFAULT_DISCORD_AWARENESS_EMOJI,
@@ -841,10 +847,13 @@ type InboundResponder = {
841
847
  * For an entry whose write threw after storing it, found in the store and
842
848
  * counted as written, it is what landed, joined the same way, which for a
843
849
  * 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. */
850
+ * back (storedCopiesOf logs it) leaves a sharded body as given. `parts` is
851
+ * how many store messages hold it, as read back: a sharded body's shards,
852
+ * consecutive from `id`, and 1 where it couldn't be read back. */
845
853
  interface DrainedWrite {
846
854
  id: MessageId;
847
855
  content: ContentBlock[];
856
+ parts: number;
848
857
  }
849
858
 
850
859
  /** What later compiles render from the store for the copies of one write
@@ -860,12 +869,33 @@ function asCompiled(landed: ReadonlyArray<{ content: ContentBlock[] }>): Content
860
869
  const text = blocks.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text).join('');
861
870
  return [...blocks.filter((b) => b.type !== 'text'), { type: 'text', text } as ContentBlock];
862
871
  }
872
+
873
+ /** A message's size as a notice gives it: its text's characters and its
874
+ * images, each left out when there are none, so empty when it has
875
+ * neither. */
876
+ function contentSize(content: readonly ContentBlock[]): string {
877
+ let chars = 0;
878
+ let images = 0;
879
+ for (const b of content) {
880
+ if (b.type === 'text') chars += b.text.length;
881
+ else if (b.type === 'image' || b.type === 'generated_image') images++;
882
+ }
883
+ const parts: string[] = [];
884
+ if (chars > 0) parts.push(`${chars.toLocaleString('en-US')} character${chars === 1 ? '' : 's'}`);
885
+ if (images > 0) parts.push(`${images} image${images === 1 ? '' : 's'}`);
886
+ return parts.join(' and ');
887
+ }
888
+
863
889
  /** One stored copy of a deferred write, as the drain reads it back: a
864
890
  * sharded body's copies carry its `bodyGroupId`. */
865
891
  type StoredCopy = { id: MessageId; content: ContentBlock[]; bodyGroupId?: string };
866
892
  const storedCopy = (m: { id: MessageId; content: ContentBlock[]; bodyGroupId?: string }): StoredCopy =>
867
893
  ({ id: m.id, content: m.content, ...(m.bodyGroupId !== undefined ? { bodyGroupId: m.bodyGroupId } : {}) });
868
894
 
895
+ /** A tool result's spill file as writeToolResultFile left it: its path, with
896
+ * an error if the write failed, or null with no writable workspace. */
897
+ type SpillWrite = { path: string; error?: string; branchLeft?: true } | null;
898
+
869
899
  /** A released agent's window as its pass to the primary reads it
870
900
  * (AgentFramework.releasedWindow). */
871
901
  type ReleasedWindow = {
@@ -900,7 +930,8 @@ function describeDeferredWrite(msg: { id: string; participant: string; content:
900
930
  function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agentName: string): boolean {
901
931
  // Exact, as membrane's role test is: only a participant equal to the
902
932
  // request's assistantParticipant (Agent's name) renders as the assistant.
903
- return msg.participant !== agentName
933
+ // It's the name as sent, which the boundary takes as the store keeps it.
934
+ return nameAsStored(msg.participant) !== agentName
904
935
  && !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
905
936
  }
906
937
 
@@ -1112,9 +1143,10 @@ type SpeechPublished = (to: PublishDestination, text: string, messageId: string
1112
1143
 
1113
1144
  /**
1114
1145
  * 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').
1146
+ * it doesn't send is held. `notice` says whether the notice for an envelope
1147
+ * that bounces or is held back as a draft is queued now, for this round's tool
1148
+ * boundary on a stream that presents injected messages ('now'), or not at all
1149
+ * ('later'), which leaves the drafts to be named as holdProseDrafts says.
1118
1150
  * `keepPrivate` is a skip_reply round: its envelopes all stay in the context,
1119
1151
  * and none is sent, bounced or held.
1120
1152
  */
@@ -1222,6 +1254,12 @@ function isTurnContinuation(reason: string): boolean {
1222
1254
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
1223
1255
  || reason === 'tool_result_guard_retry';
1224
1256
  }
1257
+
1258
+ /** Wake sources the framework generates for itself (bounce wakes,
1259
+ * continuations, retries, unsticks, admission requeues). A wake from any
1260
+ * other source — a channel message, a heartbeat or other server event, a
1261
+ * gate flush, a timer, an operator nudge — is a cause of its own. */
1262
+ const FRAMEWORK_WAKE_SOURCES = new Set(['framework', 'scheduler']);
1225
1263
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
1226
1264
  const INFERENCE_LOG_ID = 'framework/inference-log';
1227
1265
  const PROCESS_LOG_ID = 'framework/process-log';
@@ -2875,6 +2913,10 @@ export class AgentFramework {
2875
2913
  * ephemeral agents have isolated slots and are their own only readers.
2876
2914
  */
2877
2915
  private readonly sharedSlotAgents = new Set<Agent>();
2916
+ /** Rounds of tool calls, by their results array, that a save_recent_image
2917
+ * in the round counted by their share of the bound: such a round is
2918
+ * stored by its share whether or not it fits (buildStoredToolResultContent). */
2919
+ private readonly toolRoundsByShare = new WeakSet<CompletedToolCall[]>();
2878
2920
  /** The windows of forks and runs deregistered while the host was quiesced,
2879
2921
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
2880
2922
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -3612,6 +3654,8 @@ export class AgentFramework {
3612
3654
  },
3613
3655
  );
3614
3656
  framework.tuneOutCoordinator.resumeActiveEpochs();
3657
+ } else {
3658
+ framework.endTuneOutsWithoutCoordinator();
3615
3659
  }
3616
3660
 
3617
3661
  // Modules, agents, MCPL servers and the tune-out tool are all in place:
@@ -11529,6 +11573,10 @@ export class AgentFramework {
11529
11573
  // Speech happens only via speak_in_channel (its own marked voice).
11530
11574
  proseRouting: 'explicit',
11531
11575
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
11576
+ // No clock rows of its own: its merged view carries the residents'
11577
+ // clocks, and clocks it planned from its own rows alone would read the
11578
+ // time a second time beside them, or disagree with them.
11579
+ clock: undefined,
11532
11580
  };
11533
11581
  const agent = new Agent(agentConfig, contextManager, this.membrane);
11534
11582
  this.adoptToolResultGuard(agent);
@@ -11694,7 +11742,7 @@ export class AgentFramework {
11694
11742
  if (currentState.status === 'ready') {
11695
11743
  // Effective inline cap: agent_settings override → durable config →
11696
11744
  // house default, clamped to the strategy bound (see resolver).
11697
- const maxChars = this.resolveToolResultInlineCap(agent).cap;
11745
+ const { cap: maxChars, bound } = this.resolveToolResultInlineCap(agent);
11698
11746
 
11699
11747
  // Oversized results — successes AND errors — spill to a workspace
11700
11748
  // file (truncated preview + file reference) instead of being
@@ -11702,8 +11750,8 @@ export class AgentFramework {
11702
11750
  // history copy and the live wire copy below — the two must stay
11703
11751
  // byte-matched (the window stores what the membrane sends;
11704
11752
  // divergence breaks the compile prefix).
11705
- const { blocks: toolResultContent, spilled } =
11706
- await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
11753
+ const { blocks: toolResultContent, spilled, keptCap } =
11754
+ await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars, bound);
11707
11755
 
11708
11756
  // A spill writes to the workspace, and the turn can be stopped
11709
11757
  // meanwhile. A host's cancelStream, stop() and abortInference cancel
@@ -11759,7 +11807,7 @@ export class AgentFramework {
11759
11807
  const membraneResults = currentState.toolResults.map(tc =>
11760
11808
  this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id))
11761
11809
  );
11762
- agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, maxChars);
11810
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, keptCap);
11763
11811
 
11764
11812
  // Flush any messages that were deferred while this turn was in
11765
11813
  // flight: this agent's own entries, those addressed to it and, for
@@ -11772,14 +11820,21 @@ export class AgentFramework {
11772
11820
  // live turn sees them instead of the agent staying deaf until the
11773
11821
  // turn ends. The window stores them here, right after the
11774
11822
  // 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).
11823
+ // them in the live conversation — same participant, and neither
11824
+ // carries the row's metadata — and each goes in as the next compile
11825
+ // will send it, which the strategy renders (renderForInjection): cut
11826
+ // by the per-message cap, a sharded body joined, and its images as
11827
+ // the image limits leave them at the newest position. So the next
11828
+ // turn's compiled prefix matches the live request (prompt-cache
11829
+ // safe), up to what came after them: a later message can still
11830
+ // take an earlier one past an image limit, as for any message, and
11831
+ // the next compile then strips that image where the live rounds
11832
+ // carried it. One the next compile won't send among its newest
11833
+ // isn't injected; a notice after the batch tells the agent of it
11834
+ // (recordLeftOutNotice). A write that threw after it was stored, whose landing
11835
+ // couldn't be read, is the exception: it's carried at the flush
11836
+ // that finds it, after what was stored since, so there the store and
11837
+ // the live request differ (writeDrainedInOrder).
11783
11838
  //
11784
11839
  // The queue is drained ONLY at the target agent's own boundary:
11785
11840
  // draining at another agent's boundary would store the messages
@@ -11814,22 +11869,30 @@ export class AgentFramework {
11814
11869
  // a held message does, rather than sitting in the window between
11815
11870
  // rounds the stream never showed it.
11816
11871
  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>();
11872
+ // What this boundary stored for the agent to hear, in store order,
11873
+ // injected or left out, for a boundary that leaves it to the turn's
11874
+ // next start as arrivals: that start's compile sends it.
11875
+ const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[]; parts: number }> = [];
11820
11876
  if (currentState.supportsInjectedMessages !== false) {
11877
+ // A clock row due (storeDueClock) goes right after the round's
11878
+ // results, ahead of what is drained below, and joins the stream
11879
+ // the same way. A boundary that passed during the round can't
11880
+ // stand between a tool's call and its results, so this clock
11881
+ // reads the time it's written (clock-rows.ts).
11882
+ const clock = this.storeDueClock(agent, 'tool-boundary');
11821
11883
  const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
11822
11884
  // Stored in queue order, cut at the first that can't be stored: it
11823
11885
  // and what follows wait for the turn's end, and only what was
11824
11886
  // stored is injected (the round's results go on either way). One
11825
11887
  // 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).
11888
+ // follows goes on. What was stored is injected as the next compile
11889
+ // will send it (renderForInjection). Where the strategy can't say,
11890
+ // a sharded body read back from its store is injected as later
11891
+ // compiles join it, and a write that threw after storing, which
11892
+ // counts as stored, as what landed of it (writeDrainedInOrder).
11830
11893
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) =>
11831
11894
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
11832
- for (const [msg, { id, content }] of stored) {
11895
+ for (const [msg, { id, content, parts }] of stored) {
11833
11896
  // Every other resident reads what this boundary stores; for its
11834
11897
  // own agent, the boundary weighs what it injects below.
11835
11898
  this.noteForOtherResidents(agent, msg.metadata, id);
@@ -11838,13 +11901,44 @@ export class AgentFramework {
11838
11901
  // compiled view; injecting it here would put in the live turn
11839
11902
  // what the next compile hides (#232 gap 5).
11840
11903
  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);
11904
+ heard.push({ msg, id, content, parts });
11905
+ }
11906
+ // Rendered in one call: a render prepares as a compile does, so
11907
+ // the batch shares one preparation. The image limits count from
11908
+ // the batch's oldest message to the newest, as a compile does.
11909
+ // The clock, stored ahead of them, renders first in the same batch
11910
+ // and is injected first, as the next compile sends it.
11911
+ const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
11912
+ const first = clock ? 1 : 0;
11913
+ if (clock) {
11914
+ const sent = rendered ? rendered[0]! : clock.content;
11915
+ if (sent !== null) {
11916
+ midTurnInjections.push({ participant: 'user', content: sent, metadata: clock.metadata });
11917
+ } else {
11918
+ 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`);
11919
+ }
11920
+ }
11921
+ const leftOut: typeof heard = [];
11922
+ heard.forEach((h, i) => {
11923
+ const sent = rendered ? rendered[i + first]! : h.content;
11924
+ // The next compile won't send it among its newest: injected, it
11925
+ // would put in the live turn what that compile sends elsewhere,
11926
+ // or not at all. It's under any consumed mark the boundary sets
11927
+ // (below), as anything stored here is.
11928
+ if (sent === null) { leftOut.push(h); return; }
11929
+ midTurnInjections.push({
11930
+ participant: h.msg.participant,
11931
+ content: sent,
11932
+ ...(h.msg.metadata ? { metadata: h.msg.metadata } : {}),
11933
+ });
11934
+ });
11935
+ if (leftOut.length > 0) {
11936
+ 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(', ')}`);
11937
+ // The agent is told what it didn't hear, after the batch, as
11938
+ // the routing notice is: stored, so the next compile sends it
11939
+ // where the live round heard it.
11940
+ const notice = this.recordLeftOutNotice(agent, leftOut);
11941
+ if (notice) midTurnInjections.push(notice);
11848
11942
  }
11849
11943
  }
11850
11944
 
@@ -11982,10 +12076,11 @@ export class AgentFramework {
11982
12076
  }
11983
12077
  } else if (routesLocus && !shouldEndTurn) {
11984
12078
  // 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)!);
12079
+ // stream to tell) leaves what it stored for the agent to the
12080
+ // turn's next start, as arrivals: a budget restart, or a
12081
+ // re-inference on these tool results. That includes a message
12082
+ // left out of the injection, since that start compiles anew.
12083
+ for (const { msg, id } of heard) this.noteArrival(agent.name, msg.metadata, id);
11989
12084
  }
11990
12085
 
11991
12086
  if (shouldEndTurn) {
@@ -12058,11 +12153,14 @@ export class AgentFramework {
12058
12153
  // 0.5.x, and from 0.6.0 declares it can't carry them, so none are
12059
12154
  // collected for it. Each goes as its participant and content
12060
12155
  // alone: a stored row's metadata is the host's record, not input
12061
- // for membrane.
12156
+ // for membrane. Each goes with its name and text as the store
12157
+ // keeps them, whether it's a heard message, rendered or not, or a
12158
+ // notice the boundary made: this is where they leave for the
12159
+ // request.
12062
12160
  currentState.stream.provideToolResults(
12063
12161
  agent.toolResultGuard.submissionResults(membraneResults),
12064
12162
  midTurnInjections.length > 0
12065
- ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
12163
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant: nameAsStored(participant), content: contentAsStored(content) })) }
12066
12164
  : undefined,
12067
12165
  );
12068
12166
  // RFC-006 §3.3: consumption is determined at assembly, and this
@@ -12088,6 +12186,18 @@ export class AgentFramework {
12088
12186
  // Under context-manager#160 such a write already gives the
12089
12187
  // replies stored after it stamps their requests didn't carry,
12090
12188
  // whatever the mark says; deferring those writers is the fix.
12189
+ // The mark also covers a message the boundary stored and left out
12190
+ // of the injection, since the next compile won't send it among its
12191
+ // newest: the notice telling the agent of it is injected, so a
12192
+ // boundary that leaves one out injects something, unless that
12193
+ // notice couldn't be stored. The message then counts as read
12194
+ // though no request carried it: a coalesced render's wake to read
12195
+ // it drops, and a release doesn't pass it to the primary. The
12196
+ // notice has told the agent it came, from whom, and how to read
12197
+ // it. Where the view leaves it out, no request will carry it.
12198
+ // Where the memory strategy places it before its recent window, a
12199
+ // batch bigger than that window, the next compile carries it, in
12200
+ // whatever turn something else starts.
12091
12201
  if (midTurnInjections.length > 0) {
12092
12202
  // Possibly shown, whatever the stream declares: the first-view
12093
12203
  // mark (CoalescerHost.isUnshown) counts a doubt as shown.
@@ -12416,6 +12526,11 @@ export class AgentFramework {
12416
12526
  if (!target) return undefined;
12417
12527
  if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
12418
12528
  metadata.triggered = event.triggerInference ?? false;
12529
+ // A fork's channel messages are stored past its deferral; a clock goes
12530
+ // ahead of one only while the fork has no turn alive (storeDueClock).
12531
+ if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
12532
+ this.storeDueClock(target, 'event');
12533
+ }
12419
12534
  const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content));
12420
12535
  // A correction may target an older engagement. Refresh only the
12421
12536
  // binding whose agent actually received the message.
@@ -12599,6 +12714,10 @@ export class AgentFramework {
12599
12714
  const trigger = decision.trigger && event.triggerInference !== false;
12600
12715
  messageMetadata.triggered = trigger;
12601
12716
 
12717
+ // As for a correction above: a clock ahead only while no turn is alive.
12718
+ if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
12719
+ this.storeDueClock(agent, 'event');
12720
+ }
12602
12721
  const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
12603
12722
  router.touch(event.channelId);
12604
12723
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
@@ -14350,6 +14469,16 @@ export class AgentFramework {
14350
14469
  // requests[0] in that mixed batch bypassed the turn lock because a
14351
14470
  // restart existed, then treated the continuation as a fresh turn.
14352
14471
  const trigger = budgetRestart ?? requests[0];
14472
+ // The bounce-wake cap (bounceProse) stops a bounce → wake → bounce loop
14473
+ // that nothing else feeds. A turn with a cause of its own is not that
14474
+ // loop, so the count starts again. Otherwise only a delivered prose
14475
+ // envelope reset it, and a resident that recovers from bounces with a
14476
+ // send tool kept the count for good: every later bounce, a reply to a
14477
+ // fresh message included, appended its notice without a wake.
14478
+ // (Optional chaining: prototype-built harnesses leave the map unset.)
14479
+ if (requests.some((r) => !FRAMEWORK_WAKE_SOURCES.has(r.source))) {
14480
+ this.proseBounceStreaks?.delete(agentName);
14481
+ }
14353
14482
  // Route this turn's auto-published speech to the channel that triggered
14354
14483
  // it (item-3 redux). A batched wake may carry several triggering channels
14355
14484
  // (messages arrived in >1 channel while the agent was busy/idle):
@@ -15423,6 +15552,102 @@ export class AgentFramework {
15423
15552
  return routing.engaged.has(m.channelId) ? m.channelId : undefined;
15424
15553
  }
15425
15554
 
15555
+ /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */
15556
+ private turnAlive(agent: Agent): boolean {
15557
+ return this.activeTurnTokens.has(agent.name)
15558
+ || this.pendingAssistantBlocks.has(agent.name)
15559
+ || this.activeStreams.has(agent.name);
15560
+ }
15561
+
15562
+ /**
15563
+ * Store the clock row due in `agent`'s window (clock-rows.ts), if the agent
15564
+ * has clock rows and a boundary has passed since the window's last clock,
15565
+ * or the window holds no clock row yet (planClock), and return it. The
15566
+ * caller places it, by calling this right before what the clock precedes
15567
+ * is written: an incoming event while no turn of the agent's is alive (or
15568
+ * one is assembling before its compile), a fresh turn's inputs before its
15569
+ * compile, or, mid-turn, the messages a tool boundary injects, where the
15570
+ * caller injects the clock first. Each is a place where a row is either in
15571
+ * the request that follows or before every request still to come, so the
15572
+ * window and the live stream keep matching.
15573
+ *
15574
+ * None while the host is quiesced (the window is frozen for surgery), and
15575
+ * none in the residents' shared slot while another of its readers (the
15576
+ * subconscious among them, through its merged view) has a turn alive: the
15577
+ * row would sit inside that turn without joining its stream. A clock that
15578
+ * can't be planned or stored is logged and skipped: it never holds up what
15579
+ * it would have preceded. One whose write threw after it landed is the
15580
+ * window's all the same, so it's returned, and a tool boundary injects it.
15581
+ */
15582
+ private storeDueClock(
15583
+ agent: Agent,
15584
+ occasion: 'event' | 'turn-start' | 'tool-boundary',
15585
+ ): { id: MessageId; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
15586
+ const config = agent.clock;
15587
+ if (!config || this.quiesced) return undefined;
15588
+ if (this.sharedSlotAgents.has(agent)) {
15589
+ for (const reader of this.sharedSlotAgents) {
15590
+ if (reader !== agent && this.turnAlive(reader)) return undefined;
15591
+ }
15592
+ }
15593
+ let planned: ReturnType<typeof planClock>;
15594
+ try {
15595
+ planned = planClock({ now: Date.now(), ...this.lastClockOf(agent.getContextManager()), config, zone: this.timeZone });
15596
+ } catch (err) {
15597
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}:`, err);
15598
+ return undefined;
15599
+ }
15600
+ if (!planned) return undefined;
15601
+ const metadata = { system: true, kind: CLOCK_ROW_KIND, clock: planned.stamp } as MessageMetadata;
15602
+ const before = this.storedCountOf(agent);
15603
+ let id: MessageId;
15604
+ try {
15605
+ id = agent.getContextManager().addMessage('user', planned.content, metadata);
15606
+ } catch (err) {
15607
+ // A write can throw after its row landed, as when context-manager's own
15608
+ // propagation of it fails: what landed is read back from where the
15609
+ // store stood before, as writeDrainedInOrder reads a drained write's.
15610
+ const at = planned.stamp.at;
15611
+ const landed = before === undefined
15612
+ ? undefined
15613
+ : this.storedSince(agent, before, (m) => clockStampOf(m.metadata)?.at === at, 'clock', 'the clock write');
15614
+ if (!landed || landed.length === 0) {
15615
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}${landed ? '' : ' (whether it landed is unknown)'}:`, err);
15616
+ return undefined;
15617
+ }
15618
+ console.error(`[clock] ${agent.name}: the clock row at ${occasion} landed though its write threw:`, err);
15619
+ id = landed[0]!.id;
15620
+ }
15621
+ this.emitTrace({ type: 'message:added', messageId: id, source: `clock:${occasion}` });
15622
+ return { id, content: planned.content, metadata };
15623
+ }
15624
+
15625
+ /** Rows a window's last-clock scan reads at a time. */
15626
+ private static readonly CLOCK_SCAN_ROWS = 256;
15627
+
15628
+ /** What the next clock is planned from: the window's newest row's time and
15629
+ * the time its last clock row reads, found by reading back from the
15630
+ * newest row, CLOCK_SCAN_ROWS at a time, until a clock row comes, however
15631
+ * far back it is. In the steady state the newest chunk holds it, unless
15632
+ * more rows than that have come since the last clock; a window with no
15633
+ * clock row is read whole once, and then takes one (planClock). */
15634
+ private lastClockOf(cm: ContextManager): { newestRowAt: number | undefined; lastClockAt: number | undefined } {
15635
+ let newestRowAt: number | undefined;
15636
+ for (let end = cm.getMessageCount(); end > 0;) {
15637
+ const span = Math.min(end, AgentFramework.CLOCK_SCAN_ROWS);
15638
+ const { messages } = cm.getMessageWindow(end - span, span, { resolveBlobs: false });
15639
+ if (newestRowAt === undefined && messages.length > 0) {
15640
+ newestRowAt = new Date(messages[messages.length - 1]!.timestamp).getTime();
15641
+ }
15642
+ for (let i = messages.length - 1; i >= 0; i--) {
15643
+ const stamp = clockStampOf(messages[i]!.metadata);
15644
+ if (stamp) return { newestRowAt, lastClockAt: stamp.at };
15645
+ }
15646
+ end -= span;
15647
+ }
15648
+ return { newestRowAt, lastClockAt: undefined };
15649
+ }
15650
+
15426
15651
  /**
15427
15652
  * Weigh a message that no tool boundary of the agent's open logical turn
15428
15653
  * will weigh: one that would re-pin the locus becomes one of the turn's
@@ -16430,7 +16655,9 @@ export class AgentFramework {
16430
16655
  }
16431
16656
  }
16432
16657
 
16433
- /** Cap on consecutive bounce-triggered wakes (notices still append after). */
16658
+ /** Cap on consecutive bounce-triggered wakes (notices still append after).
16659
+ * The count resets on a delivered prose envelope, and when a turn starts
16660
+ * with a cause of its own (processInferenceRequests). */
16434
16661
  private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
16435
16662
 
16436
16663
  /**
@@ -16971,6 +17198,16 @@ export class AgentFramework {
16971
17198
  // turn, and flushing there would insert messages between its rounds.
16972
17199
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
16973
17200
  // products — undoing the turn must not destroy them.
17201
+ // A clock row due (storeDueClock) goes ahead of the turn's inputs, so a
17202
+ // turn starts with a current clock, a silent heartbeat's tick (which
17203
+ // stores no row of its own) included, unless storeDueClock writes none:
17204
+ // while the host is quiesced, while another reader of the residents'
17205
+ // shared slot has a turn alive, or when its write fails without landing.
17206
+ // Then a later event, turn start or tool boundary takes it. Fresh turns
17207
+ // only, as the flush below: a turn that resumes continues its logical
17208
+ // turn, which took its clock at its start or at a tool boundary that
17209
+ // could carry one.
17210
+ if (attempt === 0 && !resumesTurn) this.storeDueClock(agent, 'turn-start');
16974
17211
  if (
16975
17212
  attempt === 0 &&
16976
17213
  !resumesTurn &&
@@ -18508,15 +18745,16 @@ export class AgentFramework {
18508
18745
  // one door a giant blob still walks through).
18509
18746
  const readyState = agent.state as AgentState;
18510
18747
  if (readyState.status === 'ready') {
18511
- const cap = this.resolveToolResultInlineCap(agent).cap;
18512
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(
18748
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
18749
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(
18513
18750
  agent.name,
18514
18751
  readyState.toolResults,
18515
18752
  cap,
18753
+ bound,
18516
18754
  );
18517
18755
  agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) =>
18518
18756
  this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults,
18519
- this.silentTurnRowMetadata(agent.name), cap);
18757
+ this.silentTurnRowMetadata(agent.name), keptCap);
18520
18758
  // The response is already complete: this batch is never
18521
18759
  // submitted in this turn, so it cannot be refused. Admit it.
18522
18760
  agent.toolResultGuard.settleTurnEnded();
@@ -19826,10 +20064,12 @@ export class AgentFramework {
19826
20064
  // token (as during a real tool call) and lands AFTER the pair, never
19827
20065
  // between tool_use and its tool_result. The two addMessage calls
19828
20066
  // below are synchronous and adjacent — nothing can interleave.
20067
+ const { bound } = this.resolveToolResultInlineCap(agent);
19829
20068
  const { blocks } = await this.buildStoredToolResultContent(
19830
20069
  agentName,
19831
20070
  [{ id: toolUseId, name: toolName, input, result, durationMs }],
19832
20071
  call.resultMaxChars,
20072
+ bound,
19833
20073
  );
19834
20074
  const toolUse: ContentBlock[] = [
19835
20075
  { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
@@ -20291,10 +20531,12 @@ export class AgentFramework {
20291
20531
  // An agent that has gone gets nothing, its drop logged once (noticeTargetFor).
20292
20532
  if (!this.noticeTargetFor(agentName, `script ${scriptId} wake`)) throw new Error(`agent ${agentName} has no context to wake`);
20293
20533
  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}`);
20534
+ // A reader's strategy cuts the wake's whole message, so its spill fits the
20535
+ // readers' bound with its notice.
20536
+ const { cap: maxChars, bound } = agent
20537
+ ? this.resolveToolResultInlineCap(agent)
20538
+ : { cap: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, bound: undefined };
20539
+ const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`, null, bound);
20298
20540
  // The spill can take a while: the agent may have gone since, or the host begun to stop.
20299
20541
  if (this.stopHasBegun()) throw new Error('this host is stopping');
20300
20542
  const target = this.noticeTargetFor(agentName, `script ${scriptId} wake`);
@@ -20344,15 +20586,38 @@ export class AgentFramework {
20344
20586
  agentName: string,
20345
20587
  toolResults: CompletedToolCall[],
20346
20588
  maxChars: number | undefined,
20589
+ /** The readers' smallest per-message bound (resolveToolResultInlineCap). */
20590
+ bound: number | undefined = undefined,
20347
20591
  ): Promise<{
20348
20592
  blocks: ContentBlock[];
20349
20593
  spilled: Map<string, { text: string; filePath: string | null }>;
20594
+ /** Where a withheld stub's kept fields are cut: the share's keptCap
20595
+ * (toolResultShare), which is the cap where there's no bound. */
20596
+ keptCap: number | undefined;
20350
20597
  }> {
20351
20598
  const spilled = new Map<string, { text: string; filePath: string | null }>();
20352
20599
  const dateLabel = new Date().toISOString().slice(0, 10);
20600
+ // The results are stored as one message, whose bound a reader's
20601
+ // strategy spends across them in order. Each is stored as it would be
20602
+ // alone, unless together they pass the bound, or a save in the round
20603
+ // counted the round by its share (toolRoundsByShare): then each is
20604
+ // stored under its share (toolResultShare). A result carrying an image
20605
+ // keeps its live copy cut at the cap; any other's live copy is its stored
20606
+ // text.
20607
+ const alone = new Map<string, { text: string; shown: ShownRanges | null; stored: { text: string; filePath: string | null; written?: SpillWrite } }>();
20353
20608
  for (const tc of toolResults) {
20354
20609
  const { text, shown } = this.storedToolResultText(agentName, tc, maxChars);
20355
- spilled.set(tc.id, await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown));
20610
+ alone.set(tc.id, { text, shown, stored: await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown, bound) });
20611
+ }
20612
+ const share = maxChars === undefined ? undefined : toolResultShare(maxChars, bound, toolResults.length);
20613
+ const total = [...alone.values()].reduce((sum, { stored }) => sum + stored.text.length, 0);
20614
+ const byShare = share?.bound !== undefined && toolResults.length > 1
20615
+ && (this.toolRoundsByShare.has(toolResults) || total > bound!);
20616
+ for (const tc of toolResults) {
20617
+ const { text, shown, stored } = alone.get(tc.id)!;
20618
+ spilled.set(tc.id, byShare
20619
+ ? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written)
20620
+ : stored);
20356
20621
  }
20357
20622
  const blocks: ContentBlock[] = toolResults.map(tc => ({
20358
20623
  type: 'tool_result' as const,
@@ -20363,7 +20628,10 @@ export class AgentFramework {
20363
20628
  content: spilled.get(tc.id)!.text,
20364
20629
  isError: tc.result.isError,
20365
20630
  }));
20366
- return { blocks, spilled };
20631
+ // A withheld round's stubs carry the guard's notice and place beside the
20632
+ // fields they keep, so under a bound their fields are cut within the
20633
+ // share, whether or not the round's results were stored by it.
20634
+ return { blocks, spilled, keptCap: share?.keptCap ?? maxChars };
20367
20635
  }
20368
20636
 
20369
20637
  /**
@@ -20386,7 +20654,7 @@ export class AgentFramework {
20386
20654
  tc: CompletedToolCall,
20387
20655
  maxChars: number | undefined,
20388
20656
  ): { text: string; shown: ShownRanges | null } {
20389
- if (tc.result.isError) return { text: tc.result.error ?? 'Unknown error', shown: null };
20657
+ if (tc.result.isError) return { text: (tc.result.error ?? 'Unknown error').toWellFormed(), shown: null };
20390
20658
  const ledger = this.toolImageLedgerFor(agentName);
20391
20659
  let blocks = null as HistoryBlockSpan[] | null;
20392
20660
  const text = nonErrorResultText(tc.result, undefined, {
@@ -20492,7 +20760,7 @@ export class AgentFramework {
20492
20760
  label: string,
20493
20761
  content: string,
20494
20762
  branch?: string,
20495
- ): Promise<{ path: string; error?: string; branchLeft?: true } | null> {
20763
+ ): Promise<SpillWrite> {
20496
20764
  const place = this.toolResultFilePlace(label);
20497
20765
  if (!place) return null;
20498
20766
  const { workspace, path, safeLabel } = place;
@@ -20536,16 +20804,18 @@ export class AgentFramework {
20536
20804
  }
20537
20805
 
20538
20806
  /** 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 {
20807
+ * sized to come back within `cap`, and, where `canRaise`, that raising
20808
+ * the inline cap would show more: where a cap at the readers' bound would
20809
+ * keep a longer head, or the whole result. */
20810
+ private spillPageAdvice(path: string, cap: number, canRaise: boolean): string {
20541
20811
  // JSON serialization can expand each read-back code unit to six
20542
20812
  // chars (\\u0000). Leave room for the page's metadata and a possible
20543
20813
  // extra code unit at a surrogate-pair boundary.
20544
20814
  const pageOverhead = JSON.stringify(path).length + 256;
20545
20815
  const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
20546
20816
  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.';
20817
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars.'
20818
+ + (canRaise ? ' Or raise the inline cap via agent_settings update tool_result_inline_max_chars.' : '');
20549
20819
  }
20550
20820
 
20551
20821
  private async spillOrTruncate(
@@ -20555,7 +20825,14 @@ export class AgentFramework {
20555
20825
  /** What the live copy showed beside this text (storedToolResultText);
20556
20826
  * null when the live copy was this text. */
20557
20827
  shown: ShownRanges | null = null,
20558
- ): Promise<{ text: string; filePath: string | null }> {
20828
+ /** The most a reader's strategy keeps of this copy: the readers'
20829
+ * smallest per-message bound, or the result's share of it
20830
+ * (buildStoredToolResultContent). */
20831
+ bound: number | undefined = undefined,
20832
+ /** The spill file an earlier cut of this text wrote: reused, not written
20833
+ * again. Undefined when none was cut. */
20834
+ written?: SpillWrite,
20835
+ ): Promise<{ text: string; filePath: string | null; written?: SpillWrite }> {
20559
20836
  if (!cap || content.length <= cap) return { text: content, filePath: null };
20560
20837
  // The stored text is the only place save_recent_image discovers tool
20561
20838
  // images. When the live wire delivered them beside the text, whatever
@@ -20563,20 +20840,24 @@ export class AgentFramework {
20563
20840
  // saw that the index would silently slide past (issue #104), so the
20564
20841
  // newest slots past the cut that the live copy showed are re-appended
20565
20842
  // 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);
20843
+ // past the cut is kept (#403). The stored copy, notice and kept slots
20844
+ // included, fits `bound`, the most a reader's strategy keeps of it, so
20845
+ // no reader's cut takes the notice (cutStoredToolResultToFit), and the
20846
+ // spill notices name the head they kept.
20847
+ const cut = cutStoredToolResultToFit(content, cap, shown, bound);
20568
20848
  const { head } = cut;
20569
20849
  const kept = formatKeptImageSlots(cut);
20570
20850
 
20571
- const written = await this.writeToolResultFile(label, content);
20851
+ if (written === undefined) written = await this.writeToolResultFile(label, content);
20572
20852
  if (written && written.error === undefined) {
20573
20853
  const path = written.path;
20574
20854
  return {
20575
20855
  text: head
20576
20856
  + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
20577
- + `. ${this.spillPageAdvice(path, cap)}]`
20857
+ + `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
20578
20858
  + kept,
20579
20859
  filePath: path,
20860
+ written,
20580
20861
  };
20581
20862
  }
20582
20863
  if (written) {
@@ -20589,6 +20870,7 @@ export class AgentFramework {
20589
20870
  + ` (${written.error}); content over the cap was not retained]`
20590
20871
  + kept,
20591
20872
  filePath: null,
20873
+ written,
20592
20874
  };
20593
20875
  }
20594
20876
  return {
@@ -20596,6 +20878,7 @@ export class AgentFramework {
20596
20878
  + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]'
20597
20879
  + kept,
20598
20880
  filePath: null,
20881
+ written,
20599
20882
  };
20600
20883
  }
20601
20884
 
@@ -20899,8 +21182,10 @@ export class AgentFramework {
20899
21182
  * the array is text-only (let JSON path handle it; saves a code path).
20900
21183
  * `maxChars`, when provided, caps each accompanying text block so an image
20901
21184
  * 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).
21185
+ * block is cut at the cap as the stored copy's cut at that cap is
21186
+ * (toolResultCutAt), never inside a slot, so the stored copy can keep
21187
+ * exactly the slots this showed (#403); under a readers' bound the stored
21188
+ * copy's cut can come earlier (cutStoredToolResultToFit).
20904
21189
  */
20905
21190
  private tryNativeToolResultContent(
20906
21191
  data: unknown,
@@ -20923,7 +21208,8 @@ export class AgentFramework {
20923
21208
  if (b.type === 'text') {
20924
21209
  // A text that isn't a string shows as the converters' placeholder,
20925
21210
  // rather than sending the whole array to the JSON fallback.
20926
- let text = typeof b.text === 'string' ? b.text : NON_STRING_TEXT_PLACEHOLDER;
21211
+ // Well formed, as the stored copy's text is (nonErrorResultText).
21212
+ let text = typeof b.text === 'string' ? b.text.toWellFormed() : NON_STRING_TEXT_PLACEHOLDER;
20927
21213
  if (maxChars && text.length > maxChars) {
20928
21214
  text = text.slice(0, toolResultCutAt(text, maxChars))
20929
21215
  + '\n\n[truncated — original was ' + text.length + ' chars'
@@ -20977,12 +21263,21 @@ export class AgentFramework {
20977
21263
  * (Sol's #94 ruling: a durable preference must not be a durable path for
20978
21264
  * one tool result to exceed maxMessageTokens; the way to see the whole
20979
21265
  * result is the spill file, not an over-bound blob in live context).
21266
+ * An ordinary resident's results land in the shared slot, which each of
21267
+ * its readers compiles under its own strategy, so the bound is the
21268
+ * smallest of theirs, as the tune-out dump's is (residentMessageChars); a
21269
+ * fork's or an ephemeral agent's store has itself as its only reader.
20980
21270
  */
20981
21271
  private resolveToolResultInlineCap(agent: Agent): {
20982
21272
  cap: number;
20983
21273
  source: 'agent-settings-override' | 'framework-config' | 'default';
20984
- /** Set when the desired value was reduced to the strategy bound. */
20985
- clampedBy: 'strategy-bound' | null;
21274
+ /** Set when the desired value was reduced to a bound: the agent's own
21275
+ * strategy's, or a smaller one of another reader of its slot. */
21276
+ clampedBy: 'strategy-bound' | 'reader-bound' | null;
21277
+ /** The smallest per-message bound among the readers of the agent's
21278
+ * store, which a stored copy, notice included, must fit
21279
+ * (cutStoredToolResultToFit); undefined when no reader has one. */
21280
+ bound: number | undefined;
20986
21281
  } {
20987
21282
  const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
20988
21283
  const configured = this.toolResultInlineMaxCharsConfig;
@@ -20990,12 +21285,24 @@ export class AgentFramework {
20990
21285
  const source = override !== undefined
20991
21286
  ? 'agent-settings-override' as const
20992
21287
  : configured !== null ? 'framework-config' as const : 'default' as const;
20993
- const strategyBound = this.strategyDerivedToolResultChars(agent);
20994
- const clamped = strategyBound !== undefined && strategyBound < desired;
21288
+ let bound = this.strategyDerivedToolResultChars(agent);
21289
+ let byReader = false;
21290
+ if (this.isOrdinaryResident(agent)) {
21291
+ for (const reader of this.sharedSlotAgents) {
21292
+ if (reader === agent) continue;
21293
+ const readerBound = this.strategyDerivedToolResultChars(reader);
21294
+ if (readerBound !== undefined && (bound === undefined || readerBound < bound)) {
21295
+ bound = readerBound;
21296
+ byReader = true;
21297
+ }
21298
+ }
21299
+ }
21300
+ const clamped = bound !== undefined && bound < desired;
20995
21301
  return {
20996
- cap: clamped ? strategyBound : desired,
21302
+ cap: clamped ? bound! : desired,
20997
21303
  source,
20998
- clampedBy: clamped ? 'strategy-bound' : null,
21304
+ clampedBy: clamped ? (byReader ? 'reader-bound' : 'strategy-bound') : null,
21305
+ bound,
20999
21306
  };
21000
21307
  }
21001
21308
 
@@ -21004,8 +21311,9 @@ export class AgentFramework {
21004
21311
  * its caller whole. A call from a running script resolves to the script,
21005
21312
  * which takes any result that fits its memory (scriptResultMaxChars). Any
21006
21313
  * 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.
21314
+ * inline cap, unless its round's results together pass the readers' bound,
21315
+ * when each takes its share (buildStoredToolResultContent), and a call for
21316
+ * an agent the framework doesn't hold gets the house default.
21009
21317
  */
21010
21318
  private resultMaxCharsFor(agentName: string, callId: string): number {
21011
21319
  const ce = this.codeExecution;
@@ -21653,8 +21961,9 @@ export class AgentFramework {
21653
21961
  // messages flush at the next turn's START (before its compile — riding
21654
21962
  // their own wake), at the agent's next tool boundary (where they are
21655
21963
  // 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.
21964
+ // message a tune-out holds from the residents, and one the next compile
21965
+ // won't send among its newest), or in driveStream's finally when the
21966
+ // turn ends.
21658
21967
  // Quiesce (issue #122): the window exists to run compression/refold
21659
21968
  // against a FROZEN context — module events and api message.send must not
21660
21969
  // append mid-surgery. Deferred here, flushed by resume(). tool_result
@@ -21722,6 +22031,12 @@ export class AgentFramework {
21722
22031
  return '' as MessageId; // Deferred — flushed at the target's next boundary
21723
22032
  }
21724
22033
 
22034
+ // A clock row due goes first (storeDueClock). A write that gets here is a
22035
+ // tool result (which takes none), or no turn of the agent's is alive, or
22036
+ // its turn is assembling before its compile, or it comes from stop()'s
22037
+ // drain, once every stream it could join has settled: the clock is in
22038
+ // whatever request comes next.
22039
+ if (takesClockBefore(participant, content, metadata, agent.name)) this.storeDueClock(agent, 'event');
21725
22040
  const stored = agent.getContextManager().addMessage(
21726
22041
  participant,
21727
22042
  content,
@@ -21805,6 +22120,74 @@ export class AgentFramework {
21805
22120
  if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites();
21806
22121
  }
21807
22122
 
22123
+ /**
22124
+ * What the next compile sends for each of `ids`, messages a tool boundary
22125
+ * has just stored, as the agent's strategy renders them as the newest of
22126
+ * its window (ContextManager.renderAsNewest): cut by the per-message cap,
22127
+ * a sharded body joined, and the image limits applied over the batch, so
22128
+ * the live stream hears what the next compile sends. An id comes back as
22129
+ * null where that compile won't send the message among its newest.
22130
+ * Undefined where the strategy has no such hook, or the render threw,
22131
+ * which is logged: the boundary then injects what it stored, as it did
22132
+ * before strategies could say.
22133
+ */
22134
+ private renderForInjection(agent: Agent, ids: MessageId[]): Array<ContentBlock[] | null> | undefined {
22135
+ if (ids.length === 0) return undefined;
22136
+ try {
22137
+ return agent.getContextManager().renderAsNewest(ids);
22138
+ } catch (error) {
22139
+ 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);
22140
+ return undefined;
22141
+ }
22142
+ }
22143
+
22144
+ /**
22145
+ * Tell the agent of the messages a tool boundary stored and left out of
22146
+ * its injection, since the next compile won't send them among its newest:
22147
+ * who sent each and its size, and how to read it, `history--extract`
22148
+ * around it where the agent is shown that tool, else its id. None of a
22149
+ * message's words: the line stands in for a message the live round
22150
+ * doesn't carry. A system line, so it's no conversation: it neither
22151
+ * re-pins the turn nor clears its send suppression. It names the agent
22152
+ * it's for, since every resident sharing the slot reads it too. Returns
22153
+ * the notice as stored, for the boundary to inject after the batch, or
22154
+ * undefined when it couldn't be stored, which is logged.
22155
+ */
22156
+ private recordLeftOutNotice(
22157
+ agent: Agent,
22158
+ leftOut: ReadonlyArray<{ msg: { participant: string }; id: MessageId; content: ContentBlock[]; parts: number }>,
22159
+ ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
22160
+ const canExtract = this.advertisedToolsForAgent(agent.name).some((t) => t.name === 'history--extract');
22161
+ const one = leftOut.length === 1;
22162
+ const lines = leftOut.map(({ msg, id, content, parts }) => {
22163
+ const size = contentSize(content);
22164
+ // A sharded body is that many store messages, consecutive from its
22165
+ // first, which extract reads around it one by one. (Its "after" stops
22166
+ // at 100, so a body of more parts, over 300,000 tokens at the memory
22167
+ // strategy's default shard size, is read only that far.)
22168
+ const read = canExtract
22169
+ ? `history--extract with aroundId "${id}", before 0 and after ${parts - 1} reads it` +
22170
+ `${parts > 1 ? `, stored as ${parts} parts` : ''}.`
22171
+ : `Its id is ${id}${parts > 1 ? `, the first of the ${parts} parts it's stored as` : ''}.`;
22172
+ return `- From ${msg.participant}${size ? `, ${size}` : ''}. ${read}`;
22173
+ });
22174
+ const content: ContentBlock[] = [{
22175
+ type: 'text',
22176
+ text: `[not shown] For ${agent.name}: ${one ? 'A message' : `${leftOut.length} messages`} arrived during your turn that ` +
22177
+ `${one ? "isn't" : "aren't"} shown here, since your context doesn't keep ${one ? 'it' : 'them'} among ` +
22178
+ `your newest messages:\n${lines.join('\n')}`,
22179
+ }];
22180
+ const metadata = { system: true, kind: 'left-out-notice' } as MessageMetadata;
22181
+ try {
22182
+ const id = agent.getContextManager().addMessage('user', content, metadata);
22183
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'left-out-notice' });
22184
+ return { participant: 'user', content, metadata };
22185
+ } catch (err) {
22186
+ console.error(`[injection] ${agent.name}: couldn't store the notice of messages left out of the live round:`, err);
22187
+ return undefined;
22188
+ }
22189
+ }
22190
+
21808
22191
  /**
21809
22192
  * Write drained entries in queue (seq) order, whatever their targets:
21810
22193
  * residents share one message slot, so the order entries arrived in is the
@@ -21927,7 +22310,7 @@ export class AgentFramework {
21927
22310
  continue;
21928
22311
  }
21929
22312
  if (found.length > 0) {
21930
- written.set(msg, { id: found[0]!.id, content: asCompiled(found) });
22313
+ written.set(msg, { id: found[0]!.id, content: asCompiled(found), parts: found.length });
21931
22314
  this.logDeferredWriteError(msg, where, undefined, { count: found.length, sharded: found[0]!.bodyGroupId !== undefined, foundLater: true });
21932
22315
  onLanded?.(msg, found[0]!.id);
21933
22316
  continue;
@@ -21944,14 +22327,14 @@ export class AgentFramework {
21944
22327
  const copies = agent && before !== undefined && (this.storedCountOf(agent) ?? before) - before > 1
21945
22328
  ? this.storedCopiesOf(agent, before, msg.id) ?? []
21946
22329
  : [];
21947
- written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content });
22330
+ written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content, parts: Math.max(1, copies.length) });
21948
22331
  } catch (error) {
21949
22332
  // What landed, read back from where the store stood before the
21950
22333
  // write; undefined when the store couldn't say, before the write or
21951
22334
  // after it. With no agent there's no store it could have landed in.
21952
22335
  const landed = !agent ? [] : before === undefined ? undefined : this.storedCopiesOf(agent, before, msg.id);
21953
22336
  if (landed && landed.length > 0) {
21954
- written.set(msg, { id: landed[0]!.id, content: asCompiled(landed) });
22337
+ written.set(msg, { id: landed[0]!.id, content: asCompiled(landed), parts: landed.length });
21955
22338
  this.logDeferredWriteError(msg, where, error, { count: landed.length, sharded: landed[0]!.bodyGroupId !== undefined });
21956
22339
  onLanded?.(msg, landed[0]!.id);
21957
22340
  continue;
@@ -21992,13 +22375,26 @@ export class AgentFramework {
21992
22375
  * `bodyGroupId`. Undefined when the store can't be read back, which is
21993
22376
  * logged: not the same as nothing found. */
21994
22377
  private storedCopiesOf(agent: Agent, from: number, deferredWriteId: string): StoredCopy[] | undefined {
22378
+ return this.storedSince(
22379
+ agent, from, (m) => m.metadata?.deferredWriteId === deferredWriteId, 'deferral', `deferred write ${deferredWriteId}`);
22380
+ }
22381
+
22382
+ /** The messages `match` picks among those the agent's store gained from
22383
+ * position `from` on, in store order, with their blobs resolved. Undefined
22384
+ * when the store can't be read back, which is logged under `tag`, naming
22385
+ * `what` was written: not the same as nothing found. */
22386
+ private storedSince(
22387
+ agent: Agent,
22388
+ from: number,
22389
+ match: (m: StoredMessage) => boolean,
22390
+ tag: string,
22391
+ what: string,
22392
+ ): StoredCopy[] | undefined {
21995
22393
  try {
21996
22394
  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);
22395
+ return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages.filter(match).map(storedCopy);
22000
22396
  } catch (error) {
22001
- console.error(`[deferral] could not read back what deferred write ${deferredWriteId} stored in ${agent.name}'s store:`, error);
22397
+ console.error(`[${tag}] could not read back what ${what} stored in ${agent.name}'s store:`, error);
22002
22398
  return undefined;
22003
22399
  }
22004
22400
  }
@@ -25185,6 +25581,9 @@ export class AgentFramework {
25185
25581
  // configured servers can still deploy its first one at runtime.
25186
25582
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
25187
25583
  await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
25584
+ // The lifecycle log's replay restored any tune-out on record, and no
25585
+ // coordinator is made here to carry one.
25586
+ this.endTuneOutsWithoutCoordinator();
25188
25587
  }
25189
25588
 
25190
25589
  const collision = this.mcplPrefixCollision(config, undefined, claim);
@@ -27050,6 +27449,47 @@ export class AgentFramework {
27050
27449
  };
27051
27450
  }
27052
27451
 
27452
+ /**
27453
+ * A tune-out on record where this host has no tune-out coordinator: a
27454
+ * start with no subconscious makes none, and neither does a channel
27455
+ * subsystem brought up after start by connectMcplServer. Nothing then
27456
+ * diverts the channel's traffic or ever cancels its tune-out, tune_out
27457
+ * isn't offered, and channel_open, channel_close and channel_decline
27458
+ * refuse a tuned-out channel, so the channel would stay held by a decision
27459
+ * nothing here can change. Each one ends here instead, its channel decided
27460
+ * open again as a cancel leaves it, under the tune-out's own source, so an
27461
+ * agent's tune-out keeps the agent's provenance (the housekeeping guard
27462
+ * reads it). A log line names the channel and says how many of the
27463
+ * messages it diverted are held here, stored or still queued to be
27464
+ * written, which nothing will now deliver as its backlog.
27465
+ */
27466
+ private endTuneOutsWithoutCoordinator(): void {
27467
+ const registry = this.channelRegistry;
27468
+ if (!registry || this.tuneOutCoordinator) return;
27469
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
27470
+ const ofEpoch = (epochId: string) => (m: { metadata?: unknown }): boolean =>
27471
+ (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId;
27472
+ for (const { serverId, channelId, params, source } of registry.tuneOutsOnRecord()) {
27473
+ const diverted = typeof params.epochId !== 'string' ? 0
27474
+ : (primary?.getContextManager().getAllMessages() ?? []).filter(ofEpoch(params.epochId)).length +
27475
+ this.deferredMessages.filter((m) => (m.forAgent ?? this.primaryAgentName) === this.primaryAgentName)
27476
+ .filter(ofEpoch(params.epochId)).length;
27477
+ const held = diverted === 0 ? ''
27478
+ : diverted === 1 ? ' The 1 message it diverted stays here, and nothing will deliver it as its backlog.'
27479
+ : ` The ${diverted} messages it diverted stay here, and nothing will deliver them as its backlog.`;
27480
+ try {
27481
+ registry.cancelTuneOut(serverId, channelId, 'open', source);
27482
+ } catch (err) {
27483
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server is tuned out, and nothing on this host watches ` +
27484
+ `or cancels tune-outs, but ending this one failed, so it stays recorded until a start that can end it: ` +
27485
+ `${err instanceof Error ? err.message : String(err)}.${held}`);
27486
+ continue;
27487
+ }
27488
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server was tuned out, and nothing on this host watches ` +
27489
+ `or cancels tune-outs, so the tune-out ends here and the channel is decided open again.${held}`);
27490
+ }
27491
+ }
27492
+
27053
27493
  /**
27054
27494
  * Build BeforeInferenceParams from agent state and trigger context.
27055
27495
  */
@@ -27924,11 +28364,24 @@ export class AgentFramework {
27924
28364
  // carries the same ref.
27925
28365
  const state = agent.state;
27926
28366
  if (state.status === 'waiting_for_tools') {
27927
- const cap = this.resolveToolResultInlineCap(agent).cap;
28367
+ // The commit stores a round as each result would be alone, or by
28368
+ // its share where they pass the bound together, which a sibling
28369
+ // still running can decide. Where the two ways differ for a result
28370
+ // counted here, the round is marked to be stored by its share
28371
+ // (toolRoundsByShare), so the count holds either way: a result
28372
+ // counted before the mark is the same both ways. The share is of
28373
+ // the round's calls, finished or not.
28374
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
28375
+ const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
27928
28376
  for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
27929
28377
  const tc = state.completed[i]!;
27930
28378
  const { text, shown } = this.storedToolResultText(agentName, tc, cap);
27931
- considerToolResultImages(tc.id, tc.name, storedImageSlots(text, cap, shown), 0);
28379
+ const alone = storedImageSlots(text, cap, shown, bound);
28380
+ const byShare = storedImageSlots(text, share.cap, shown, share.bound);
28381
+ if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j]!.text)) {
28382
+ this.toolRoundsByShare.add(state.completed);
28383
+ }
28384
+ considerToolResultImages(tc.id, tc.name, this.toolRoundsByShare.has(state.completed) ? byShare : alone, 0);
27932
28385
  }
27933
28386
  }
27934
28387