@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
@@ -46,7 +46,7 @@ import { SEND_TOOLS, bareToolName } from './send-tools.js';
46
46
  import { boundedErrorMessage, errorMessage } from './error-message.js';
47
47
  import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls } from './provider-waits.js';
48
48
  import { nonErrorResultText, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
49
- import { TOOL_IMAGE_REF_RE, ToolImageLedger, cutStoredToolResult, formatKeptImageSlots, parseImagePlaceholders, sha256Hex, storedImageSlots, toolResultCutAt, } from './tool-image-ledger.js';
49
+ import { TOOL_IMAGE_REF_RE, ToolImageLedger, cutStoredToolResultToFit, formatKeptImageSlots, parseImagePlaceholders, sha256Hex, storedImageSlots, toolResultCutAt, toolResultShare, } from './tool-image-ledger.js';
50
50
  import { randomUUID, createHash } from 'node:crypto';
51
51
  import { describeCodeExecution, CODE_EXECUTION_TOOL_NAME, isolateSettings, validateCodeExecutionConfig, } from './code-execution/tool-definition.js';
52
52
  import { AgentScripts, endingWakes, ScriptRefusal, } from './code-execution/isolate/scripts.js';
@@ -56,6 +56,7 @@ import { bounded, renderList, renderReturn, VALUE_CHARS } from './code-execution
56
56
  import { splitProseRuns, withoutRoundsProse } from './prose-segments.js';
57
57
  import { cumulativeDelta } from './usage-accounting.js';
58
58
  import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
59
+ import { contentAsStored, nameAsStored } from './text-as-stored.js';
59
60
  /** An MCPL tool call refused because its server isn't in the registry: one
60
61
  * closed for good, one removed, or one whose connect failed without
61
62
  * reconnect, which this host doesn't bring back on its own. A server that's
@@ -294,9 +295,12 @@ function malformedResponseContent(response) {
294
295
  * mid-turn message's text at chars/4 and its other blocks about as
295
296
  * context-manager's store prices them (injectedBlockTokens), and a small
296
297
  * per-block envelope allowance. On membrane's native tool path a mid-turn
297
- * message rides into the next round with its stored content as it is
298
- * (provideToolResults' injectedMessages); its XML tool path drops injected
299
- * messages, so there the estimate errs high. Deliberately a
298
+ * message rides into the next round as the boundary injects it, which is as
299
+ * the strategy renders it for the next compile, cut and with its images
300
+ * limited (renderForInjection), or its stored content where the strategy
301
+ * can't say (provideToolResults' injectedMessages). Its XML tool path declares that it
302
+ * can't carry injected messages, so the tool boundary injects none there and
303
+ * that term is zero. Deliberately a
300
304
  * ceiling-ish heuristic, but for a mid-turn message's media at the store's
301
305
  * flat prices — the cost of overestimating is one early recompile;
302
306
  * the cost of underestimating is the 400 this projection exists to prevent.
@@ -533,6 +537,7 @@ import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/to
533
537
  import { boundedForLog, describeCause } from './mcpl/errors.js';
534
538
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
535
539
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
540
+ import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
536
541
  import { firstFreeBranchName } from './branch-names.js';
537
542
  import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, DiscordAwarenessRefusal, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, selectDiscordAwarenessRefs, checkedDiscordAwarenessMarks, boundDiscordAwarenessText, discordChannelOf, isPermanentDiscordReactionFailure, unsyncedWriteText, } from './recovery/discord-awareness-outbox.js';
538
543
  import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } from './operator-log.js';
@@ -572,6 +577,25 @@ function asCompiled(landed) {
572
577
  const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('');
573
578
  return [...blocks.filter((b) => b.type !== 'text'), { type: 'text', text }];
574
579
  }
580
+ /** A message's size as a notice gives it: its text's characters and its
581
+ * images, each left out when there are none, so empty when it has
582
+ * neither. */
583
+ function contentSize(content) {
584
+ let chars = 0;
585
+ let images = 0;
586
+ for (const b of content) {
587
+ if (b.type === 'text')
588
+ chars += b.text.length;
589
+ else if (b.type === 'image' || b.type === 'generated_image')
590
+ images++;
591
+ }
592
+ const parts = [];
593
+ if (chars > 0)
594
+ parts.push(`${chars.toLocaleString('en-US')} character${chars === 1 ? '' : 's'}`);
595
+ if (images > 0)
596
+ parts.push(`${images} image${images === 1 ? '' : 's'}`);
597
+ return parts.join(' and ');
598
+ }
575
599
  const storedCopy = (m) => ({ id: m.id, content: m.content, ...(m.bodyGroupId !== undefined ? { bodyGroupId: m.bodyGroupId } : {}) });
576
600
  /** Stamp a deferred write's durable id into the metadata it is stored with
577
601
  * (boot recovery dedups replays by it). Idempotent for an already-stamped
@@ -600,7 +624,8 @@ function describeDeferredWrite(msg) {
600
624
  function isInjectableMidTurn(msg, agentName) {
601
625
  // Exact, as membrane's role test is: only a participant equal to the
602
626
  // request's assistantParticipant (Agent's name) renders as the assistant.
603
- return msg.participant !== agentName
627
+ // It's the name as sent, which the boundary takes as the store keeps it.
628
+ return nameAsStored(msg.participant) !== agentName
604
629
  && !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
605
630
  }
606
631
  /**
@@ -691,6 +716,11 @@ function isTurnContinuation(reason) {
691
716
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
692
717
  || reason === 'tool_result_guard_retry';
693
718
  }
719
+ /** Wake sources the framework generates for itself (bounce wakes,
720
+ * continuations, retries, unsticks, admission requeues). A wake from any
721
+ * other source — a channel message, a heartbeat or other server event, a
722
+ * gate flush, a timer, an operator nudge — is a cause of its own. */
723
+ const FRAMEWORK_WAKE_SOURCES = new Set(['framework', 'scheduler']);
694
724
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
695
725
  const INFERENCE_LOG_ID = 'framework/inference-log';
696
726
  const PROCESS_LOG_ID = 'framework/process-log';
@@ -1983,6 +2013,10 @@ export class AgentFramework {
1983
2013
  * ephemeral agents have isolated slots and are their own only readers.
1984
2014
  */
1985
2015
  sharedSlotAgents = new Set();
2016
+ /** Rounds of tool calls, by their results array, that a save_recent_image
2017
+ * in the round counted by their share of the bound: such a round is
2018
+ * stored by its share whether or not it fits (buildStoredToolResultContent). */
2019
+ toolRoundsByShare = new WeakSet();
1986
2020
  /** The windows of forks and runs deregistered while the host was quiesced,
1987
2021
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
1988
2022
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -2630,6 +2664,9 @@ export class AgentFramework {
2630
2664
  });
2631
2665
  framework.tuneOutCoordinator.resumeActiveEpochs();
2632
2666
  }
2667
+ else {
2668
+ framework.endTuneOutsWithoutCoordinator();
2669
+ }
2633
2670
  // Modules, agents, MCPL servers and the tune-out tool are all in place:
2634
2671
  // the first point at which "matches no tool" can be judged. Later tool
2635
2672
  // refreshes re-check patterns still undecided.
@@ -9969,6 +10006,10 @@ export class AgentFramework {
9969
10006
  // Speech happens only via speak_in_channel (its own marked voice).
9970
10007
  proseRouting: 'explicit',
9971
10008
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
10009
+ // No clock rows of its own: its merged view carries the residents'
10010
+ // clocks, and clocks it planned from its own rows alone would read the
10011
+ // time a second time beside them, or disagree with them.
10012
+ clock: undefined,
9972
10013
  };
9973
10014
  const agent = new Agent(agentConfig, contextManager, this.membrane);
9974
10015
  this.adoptToolResultGuard(agent);
@@ -10115,14 +10156,14 @@ export class AgentFramework {
10115
10156
  if (currentState.status === 'ready') {
10116
10157
  // Effective inline cap: agent_settings override → durable config →
10117
10158
  // house default, clamped to the strategy bound (see resolver).
10118
- const maxChars = this.resolveToolResultInlineCap(agent).cap;
10159
+ const { cap: maxChars, bound } = this.resolveToolResultInlineCap(agent);
10119
10160
  // Oversized results — successes AND errors — spill to a workspace
10120
10161
  // file (truncated preview + file reference) instead of being
10121
10162
  // blind-truncated. Computed ONCE per call and reused for both the
10122
10163
  // history copy and the live wire copy below — the two must stay
10123
10164
  // byte-matched (the window stores what the membrane sends;
10124
10165
  // divergence breaks the compile prefix).
10125
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
10166
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars, bound);
10126
10167
  // A spill writes to the workspace, and the turn can be stopped
10127
10168
  // meanwhile. A host's cancelStream, stop() and abortInference cancel
10128
10169
  // the round's stream at `ready` and leave the agent idle, and an
@@ -10172,7 +10213,7 @@ export class AgentFramework {
10172
10213
  this.pendingAssistantBlocks.delete(agent.name);
10173
10214
  }
10174
10215
  const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars, spilled.get(tc.id)));
10175
- agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, maxChars);
10216
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, keptCap);
10176
10217
  // Flush any messages that were deferred while this turn was in
10177
10218
  // flight: this agent's own entries, those addressed to it and, for
10178
10219
  // the primary, those addressed to no one (module messages, push
@@ -10184,14 +10225,21 @@ export class AgentFramework {
10184
10225
  // live turn sees them instead of the agent staying deaf until the
10185
10226
  // turn ends. The window stores them here, right after the
10186
10227
  // tool_result message, which is exactly where the membrane appends
10187
- // them in the live conversation — same participant, same content
10188
- // blocks, and neither carries the row's metadata — and compile()
10189
- // emits recent messages verbatim, so the next turn's compiled prefix
10190
- // byte-matches the live request (prompt-cache safe). A write that
10191
- // threw after it was stored, whose landing couldn't be read, is the
10192
- // exception: it's carried at the flush that finds it, after what was
10193
- // stored since, so there the store and the live request differ
10194
- // (writeDrainedInOrder).
10228
+ // them in the live conversation — same participant, and neither
10229
+ // carries the row's metadata — and each goes in as the next compile
10230
+ // will send it, which the strategy renders (renderForInjection): cut
10231
+ // by the per-message cap, a sharded body joined, and its images as
10232
+ // the image limits leave them at the newest position. So the next
10233
+ // turn's compiled prefix matches the live request (prompt-cache
10234
+ // safe), up to what came after them: a later message can still
10235
+ // take an earlier one past an image limit, as for any message, and
10236
+ // the next compile then strips that image where the live rounds
10237
+ // carried it. One the next compile won't send among its newest
10238
+ // isn't injected; a notice after the batch tells the agent of it
10239
+ // (recordLeftOutNotice). A write that threw after it was stored, whose landing
10240
+ // couldn't be read, is the exception: it's carried at the flush
10241
+ // that finds it, after what was stored since, so there the store and
10242
+ // the live request differ (writeDrainedInOrder).
10195
10243
  //
10196
10244
  // The queue is drained ONLY at the target agent's own boundary:
10197
10245
  // draining at another agent's boundary would store the messages
@@ -10226,21 +10274,29 @@ export class AgentFramework {
10226
10274
  // a held message does, rather than sitting in the window between
10227
10275
  // rounds the stream never showed it.
10228
10276
  const midTurnInjections = [];
10229
- // The injected messages' ids, for a boundary that leaves them to the
10230
- // turn's next start as arrivals.
10231
- const injectedIds = new Map();
10277
+ // What this boundary stored for the agent to hear, in store order,
10278
+ // injected or left out, for a boundary that leaves it to the turn's
10279
+ // next start as arrivals: that start's compile sends it.
10280
+ const heard = [];
10232
10281
  if (currentState.supportsInjectedMessages !== false) {
10282
+ // A clock row due (storeDueClock) goes right after the round's
10283
+ // results, ahead of what is drained below, and joins the stream
10284
+ // the same way. A boundary that passed during the round can't
10285
+ // stand between a tool's call and its results, so this clock
10286
+ // reads the time it's written (clock-rows.ts).
10287
+ const clock = this.storeDueClock(agent, 'tool-boundary');
10233
10288
  const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
10234
10289
  // Stored in queue order, cut at the first that can't be stored: it
10235
10290
  // and what follows wait for the turn's end, and only what was
10236
10291
  // stored is injected (the round's results go on either way). One
10237
10292
  // the store refuses as malformed is dropped instead, and what
10238
- // follows goes on. A sharded body read back from its store is
10239
- // injected as later compiles render it, its shards joined, and a
10240
- // write that threw after storing, which counts as stored, as what
10241
- // landed of it (writeDrainedInOrder).
10293
+ // follows goes on. What was stored is injected as the next compile
10294
+ // will send it (renderForInjection). Where the strategy can't say,
10295
+ // a sharded body read back from its store is injected as later
10296
+ // compiles join it, and a write that threw after storing, which
10297
+ // counts as stored, as what landed of it (writeDrainedInOrder).
10242
10298
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
10243
- for (const [msg, { id, content }] of stored) {
10299
+ for (const [msg, { id, content, parts }] of stored) {
10244
10300
  // Every other resident reads what this boundary stores; for its
10245
10301
  // own agent, the boundary weighs what it injects below.
10246
10302
  this.noteForOtherResidents(agent, msg.metadata, id);
@@ -10250,13 +10306,49 @@ export class AgentFramework {
10250
10306
  // what the next compile hides (#232 gap 5).
10251
10307
  if (msg.metadata?.tuneOut)
10252
10308
  continue;
10253
- const injection = {
10254
- participant: msg.participant,
10255
- content,
10256
- ...(msg.metadata ? { metadata: msg.metadata } : {}),
10257
- };
10258
- midTurnInjections.push(injection);
10259
- injectedIds.set(injection, id);
10309
+ heard.push({ msg, id, content, parts });
10310
+ }
10311
+ // Rendered in one call: a render prepares as a compile does, so
10312
+ // the batch shares one preparation. The image limits count from
10313
+ // the batch's oldest message to the newest, as a compile does.
10314
+ // The clock, stored ahead of them, renders first in the same batch
10315
+ // and is injected first, as the next compile sends it.
10316
+ const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
10317
+ const first = clock ? 1 : 0;
10318
+ if (clock) {
10319
+ const sent = rendered ? rendered[0] : clock.content;
10320
+ if (sent !== null) {
10321
+ midTurnInjections.push({ participant: 'user', content: sent, metadata: clock.metadata });
10322
+ }
10323
+ else {
10324
+ 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`);
10325
+ }
10326
+ }
10327
+ const leftOut = [];
10328
+ heard.forEach((h, i) => {
10329
+ const sent = rendered ? rendered[i + first] : h.content;
10330
+ // The next compile won't send it among its newest: injected, it
10331
+ // would put in the live turn what that compile sends elsewhere,
10332
+ // or not at all. It's under any consumed mark the boundary sets
10333
+ // (below), as anything stored here is.
10334
+ if (sent === null) {
10335
+ leftOut.push(h);
10336
+ return;
10337
+ }
10338
+ midTurnInjections.push({
10339
+ participant: h.msg.participant,
10340
+ content: sent,
10341
+ ...(h.msg.metadata ? { metadata: h.msg.metadata } : {}),
10342
+ });
10343
+ });
10344
+ if (leftOut.length > 0) {
10345
+ 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(', ')}`);
10346
+ // The agent is told what it didn't hear, after the batch, as
10347
+ // the routing notice is: stored, so the next compile sends it
10348
+ // where the live round heard it.
10349
+ const notice = this.recordLeftOutNotice(agent, leftOut);
10350
+ if (notice)
10351
+ midTurnInjections.push(notice);
10260
10352
  }
10261
10353
  }
10262
10354
  // Deferred writes handed to the store above: sync, then drop them
@@ -10391,11 +10483,12 @@ export class AgentFramework {
10391
10483
  }
10392
10484
  else if (routesLocus && !shouldEndTurn) {
10393
10485
  // A boundary that can't re-pin now (over budget, or no live
10394
- // stream to tell) leaves its injections to the turn's next start,
10395
- // as arrivals: a budget restart, or a re-inference on these tool
10396
- // results.
10397
- for (const inj of midTurnInjections)
10398
- this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj));
10486
+ // stream to tell) leaves what it stored for the agent to the
10487
+ // turn's next start, as arrivals: a budget restart, or a
10488
+ // re-inference on these tool results. That includes a message
10489
+ // left out of the injection, since that start compiles anew.
10490
+ for (const { msg, id } of heard)
10491
+ this.noteArrival(agent.name, msg.metadata, id);
10399
10492
  }
10400
10493
  if (shouldEndTurn) {
10401
10494
  // endTurn: what this boundary stored stays; cancel stream, reset to idle.
@@ -10469,9 +10562,12 @@ export class AgentFramework {
10469
10562
  // 0.5.x, and from 0.6.0 declares it can't carry them, so none are
10470
10563
  // collected for it. Each goes as its participant and content
10471
10564
  // alone: a stored row's metadata is the host's record, not input
10472
- // for membrane.
10565
+ // for membrane. Each goes with its name and text as the store
10566
+ // keeps them, whether it's a heard message, rendered or not, or a
10567
+ // notice the boundary made: this is where they leave for the
10568
+ // request.
10473
10569
  currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0
10474
- ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
10570
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant: nameAsStored(participant), content: contentAsStored(content) })) }
10475
10571
  : undefined);
10476
10572
  // RFC-006 §3.3: consumption is determined at assembly, and this
10477
10573
  // is assembly too when the stream carries the injected messages
@@ -10496,6 +10592,18 @@ export class AgentFramework {
10496
10592
  // Under context-manager#160 such a write already gives the
10497
10593
  // replies stored after it stamps their requests didn't carry,
10498
10594
  // whatever the mark says; deferring those writers is the fix.
10595
+ // The mark also covers a message the boundary stored and left out
10596
+ // of the injection, since the next compile won't send it among its
10597
+ // newest: the notice telling the agent of it is injected, so a
10598
+ // boundary that leaves one out injects something, unless that
10599
+ // notice couldn't be stored. The message then counts as read
10600
+ // though no request carried it: a coalesced render's wake to read
10601
+ // it drops, and a release doesn't pass it to the primary. The
10602
+ // notice has told the agent it came, from whom, and how to read
10603
+ // it. Where the view leaves it out, no request will carry it.
10604
+ // Where the memory strategy places it before its recent window, a
10605
+ // batch bigger than that window, the next compile carries it, in
10606
+ // whatever turn something else starts.
10499
10607
  if (midTurnInjections.length > 0) {
10500
10608
  // Possibly shown, whatever the stream declares: the first-view
10501
10609
  // mark (CoalescerHost.isUnshown) counts a doubt as shown.
@@ -10774,6 +10882,11 @@ export class AgentFramework {
10774
10882
  return undefined;
10775
10883
  if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
10776
10884
  metadata.triggered = event.triggerInference ?? false;
10885
+ // A fork's channel messages are stored past its deferral; a clock goes
10886
+ // ahead of one only while the fork has no turn alive (storeDueClock).
10887
+ if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
10888
+ this.storeDueClock(target, 'event');
10889
+ }
10777
10890
  const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content));
10778
10891
  // A correction may target an older engagement. Refresh only the
10779
10892
  // binding whose agent actually received the message.
@@ -10933,6 +11046,10 @@ export class AgentFramework {
10933
11046
  // on top of the router's own trigger policy.
10934
11047
  const trigger = decision.trigger && event.triggerInference !== false;
10935
11048
  messageMetadata.triggered = trigger;
11049
+ // As for a correction above: a clock ahead only while no turn is alive.
11050
+ if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
11051
+ this.storeDueClock(agent, 'event');
11052
+ }
10936
11053
  const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
10937
11054
  router.touch(event.channelId);
10938
11055
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
@@ -12619,6 +12736,16 @@ export class AgentFramework {
12619
12736
  // requests[0] in that mixed batch bypassed the turn lock because a
12620
12737
  // restart existed, then treated the continuation as a fresh turn.
12621
12738
  const trigger = budgetRestart ?? requests[0];
12739
+ // The bounce-wake cap (bounceProse) stops a bounce → wake → bounce loop
12740
+ // that nothing else feeds. A turn with a cause of its own is not that
12741
+ // loop, so the count starts again. Otherwise only a delivered prose
12742
+ // envelope reset it, and a resident that recovers from bounces with a
12743
+ // send tool kept the count for good: every later bounce, a reply to a
12744
+ // fresh message included, appended its notice without a wake.
12745
+ // (Optional chaining: prototype-built harnesses leave the map unset.)
12746
+ if (requests.some((r) => !FRAMEWORK_WAKE_SOURCES.has(r.source))) {
12747
+ this.proseBounceStreaks?.delete(agentName);
12748
+ }
12622
12749
  // Route this turn's auto-published speech to the channel that triggered
12623
12750
  // it (item-3 redux). A batched wake may carry several triggering channels
12624
12751
  // (messages arrived in >1 channel while the agent was busy/idle):
@@ -13671,6 +13798,101 @@ export class AgentFramework {
13671
13798
  return m.channelId;
13672
13799
  return routing.engaged.has(m.channelId) ? m.channelId : undefined;
13673
13800
  }
13801
+ /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */
13802
+ turnAlive(agent) {
13803
+ return this.activeTurnTokens.has(agent.name)
13804
+ || this.pendingAssistantBlocks.has(agent.name)
13805
+ || this.activeStreams.has(agent.name);
13806
+ }
13807
+ /**
13808
+ * Store the clock row due in `agent`'s window (clock-rows.ts), if the agent
13809
+ * has clock rows and a boundary has passed since the window's last clock,
13810
+ * or the window holds no clock row yet (planClock), and return it. The
13811
+ * caller places it, by calling this right before what the clock precedes
13812
+ * is written: an incoming event while no turn of the agent's is alive (or
13813
+ * one is assembling before its compile), a fresh turn's inputs before its
13814
+ * compile, or, mid-turn, the messages a tool boundary injects, where the
13815
+ * caller injects the clock first. Each is a place where a row is either in
13816
+ * the request that follows or before every request still to come, so the
13817
+ * window and the live stream keep matching.
13818
+ *
13819
+ * None while the host is quiesced (the window is frozen for surgery), and
13820
+ * none in the residents' shared slot while another of its readers (the
13821
+ * subconscious among them, through its merged view) has a turn alive: the
13822
+ * row would sit inside that turn without joining its stream. A clock that
13823
+ * can't be planned or stored is logged and skipped: it never holds up what
13824
+ * it would have preceded. One whose write threw after it landed is the
13825
+ * window's all the same, so it's returned, and a tool boundary injects it.
13826
+ */
13827
+ storeDueClock(agent, occasion) {
13828
+ const config = agent.clock;
13829
+ if (!config || this.quiesced)
13830
+ return undefined;
13831
+ if (this.sharedSlotAgents.has(agent)) {
13832
+ for (const reader of this.sharedSlotAgents) {
13833
+ if (reader !== agent && this.turnAlive(reader))
13834
+ return undefined;
13835
+ }
13836
+ }
13837
+ let planned;
13838
+ try {
13839
+ planned = planClock({ now: Date.now(), ...this.lastClockOf(agent.getContextManager()), config, zone: this.timeZone });
13840
+ }
13841
+ catch (err) {
13842
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}:`, err);
13843
+ return undefined;
13844
+ }
13845
+ if (!planned)
13846
+ return undefined;
13847
+ const metadata = { system: true, kind: CLOCK_ROW_KIND, clock: planned.stamp };
13848
+ const before = this.storedCountOf(agent);
13849
+ let id;
13850
+ try {
13851
+ id = agent.getContextManager().addMessage('user', planned.content, metadata);
13852
+ }
13853
+ catch (err) {
13854
+ // A write can throw after its row landed, as when context-manager's own
13855
+ // propagation of it fails: what landed is read back from where the
13856
+ // store stood before, as writeDrainedInOrder reads a drained write's.
13857
+ const at = planned.stamp.at;
13858
+ const landed = before === undefined
13859
+ ? undefined
13860
+ : this.storedSince(agent, before, (m) => clockStampOf(m.metadata)?.at === at, 'clock', 'the clock write');
13861
+ if (!landed || landed.length === 0) {
13862
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}${landed ? '' : ' (whether it landed is unknown)'}:`, err);
13863
+ return undefined;
13864
+ }
13865
+ console.error(`[clock] ${agent.name}: the clock row at ${occasion} landed though its write threw:`, err);
13866
+ id = landed[0].id;
13867
+ }
13868
+ this.emitTrace({ type: 'message:added', messageId: id, source: `clock:${occasion}` });
13869
+ return { id, content: planned.content, metadata };
13870
+ }
13871
+ /** Rows a window's last-clock scan reads at a time. */
13872
+ static CLOCK_SCAN_ROWS = 256;
13873
+ /** What the next clock is planned from: the window's newest row's time and
13874
+ * the time its last clock row reads, found by reading back from the
13875
+ * newest row, CLOCK_SCAN_ROWS at a time, until a clock row comes, however
13876
+ * far back it is. In the steady state the newest chunk holds it, unless
13877
+ * more rows than that have come since the last clock; a window with no
13878
+ * clock row is read whole once, and then takes one (planClock). */
13879
+ lastClockOf(cm) {
13880
+ let newestRowAt;
13881
+ for (let end = cm.getMessageCount(); end > 0;) {
13882
+ const span = Math.min(end, AgentFramework.CLOCK_SCAN_ROWS);
13883
+ const { messages } = cm.getMessageWindow(end - span, span, { resolveBlobs: false });
13884
+ if (newestRowAt === undefined && messages.length > 0) {
13885
+ newestRowAt = new Date(messages[messages.length - 1].timestamp).getTime();
13886
+ }
13887
+ for (let i = messages.length - 1; i >= 0; i--) {
13888
+ const stamp = clockStampOf(messages[i].metadata);
13889
+ if (stamp)
13890
+ return { newestRowAt, lastClockAt: stamp.at };
13891
+ }
13892
+ end -= span;
13893
+ }
13894
+ return { newestRowAt, lastClockAt: undefined };
13895
+ }
13674
13896
  /**
13675
13897
  * Weigh a message that no tool boundary of the agent's open logical turn
13676
13898
  * will weigh: one that would re-pin the locus becomes one of the turn's
@@ -14622,7 +14844,9 @@ export class AgentFramework {
14622
14844
  delete routing.pin;
14623
14845
  }
14624
14846
  }
14625
- /** Cap on consecutive bounce-triggered wakes (notices still append after). */
14847
+ /** Cap on consecutive bounce-triggered wakes (notices still append after).
14848
+ * The count resets on a delivered prose envelope, and when a turn starts
14849
+ * with a cause of its own (processInferenceRequests). */
14626
14850
  static PROSE_BOUNCE_WAKE_CAP = 2;
14627
14851
  /**
14628
14852
  * Words about to be held that may use `{{unsent}}` (a bounce, or an
@@ -15133,6 +15357,17 @@ export class AgentFramework {
15133
15357
  // turn, and flushing there would insert messages between its rounds.
15134
15358
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
15135
15359
  // products — undoing the turn must not destroy them.
15360
+ // A clock row due (storeDueClock) goes ahead of the turn's inputs, so a
15361
+ // turn starts with a current clock, a silent heartbeat's tick (which
15362
+ // stores no row of its own) included, unless storeDueClock writes none:
15363
+ // while the host is quiesced, while another reader of the residents'
15364
+ // shared slot has a turn alive, or when its write fails without landing.
15365
+ // Then a later event, turn start or tool boundary takes it. Fresh turns
15366
+ // only, as the flush below: a turn that resumes continues its logical
15367
+ // turn, which took its clock at its start or at a tool boundary that
15368
+ // could carry one.
15369
+ if (attempt === 0 && !resumesTurn)
15370
+ this.storeDueClock(agent, 'turn-start');
15136
15371
  if (attempt === 0 &&
15137
15372
  !resumesTurn &&
15138
15373
  this.deferredMessages.length > 0) {
@@ -16614,9 +16849,9 @@ export class AgentFramework {
16614
16849
  // one door a giant blob still walks through).
16615
16850
  const readyState = agent.state;
16616
16851
  if (readyState.status === 'ready') {
16617
- const cap = this.resolveToolResultInlineCap(agent).cap;
16618
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap);
16619
- agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults, this.silentTurnRowMetadata(agent.name), cap);
16852
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
16853
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap, bound);
16854
+ agent.toolResultGuard.storeResults(toolResultContent, readyState.toolResults.map((tc) => this.toMembraneToolResult(tc.id, tc.result, cap, spilled.get(tc.id))), readyState.toolResults, this.silentTurnRowMetadata(agent.name), keptCap);
16620
16855
  // The response is already complete: this batch is never
16621
16856
  // submitted in this turn, so it cannot be refused. Admit it.
16622
16857
  agent.toolResultGuard.settleTurnEnded();
@@ -17852,7 +18087,8 @@ export class AgentFramework {
17852
18087
  // token (as during a real tool call) and lands AFTER the pair, never
17853
18088
  // between tool_use and its tool_result. The two addMessage calls
17854
18089
  // below are synchronous and adjacent — nothing can interleave.
17855
- const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], call.resultMaxChars);
18090
+ const { bound } = this.resolveToolResultInlineCap(agent);
18091
+ const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], call.resultMaxChars, bound);
17856
18092
  const toolUse = [
17857
18093
  { type: 'tool_use', id: toolUseId, name: toolName, input },
17858
18094
  ];
@@ -18294,10 +18530,12 @@ export class AgentFramework {
18294
18530
  if (!this.noticeTargetFor(agentName, `script ${scriptId} wake`))
18295
18531
  throw new Error(`agent ${agentName} has no context to wake`);
18296
18532
  const agent = this.agents.get(agentName);
18297
- const maxChars = agent
18298
- ? this.resolveToolResultInlineCap(agent).cap
18299
- : DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS;
18300
- const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`);
18533
+ // A reader's strategy cuts the wake's whole message, so its spill fits the
18534
+ // readers' bound with its notice.
18535
+ const { cap: maxChars, bound } = agent
18536
+ ? this.resolveToolResultInlineCap(agent)
18537
+ : { cap: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, bound: undefined };
18538
+ const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`, null, bound);
18301
18539
  // The spill can take a while: the agent may have gone since, or the host begun to stop.
18302
18540
  if (this.stopHasBegun())
18303
18541
  throw new Error('this host is stopping');
@@ -18345,12 +18583,32 @@ export class AgentFramework {
18345
18583
  * #89). Returns the stored blocks plus the per-call spill outcomes so the
18346
18584
  * live wire copy can reuse the identical strings.
18347
18585
  */
18348
- async buildStoredToolResultContent(agentName, toolResults, maxChars) {
18586
+ async buildStoredToolResultContent(agentName, toolResults, maxChars,
18587
+ /** The readers' smallest per-message bound (resolveToolResultInlineCap). */
18588
+ bound = undefined) {
18349
18589
  const spilled = new Map();
18350
18590
  const dateLabel = new Date().toISOString().slice(0, 10);
18591
+ // The results are stored as one message, whose bound a reader's
18592
+ // strategy spends across them in order. Each is stored as it would be
18593
+ // alone, unless together they pass the bound, or a save in the round
18594
+ // counted the round by its share (toolRoundsByShare): then each is
18595
+ // stored under its share (toolResultShare). A result carrying an image
18596
+ // keeps its live copy cut at the cap; any other's live copy is its stored
18597
+ // text.
18598
+ const alone = new Map();
18351
18599
  for (const tc of toolResults) {
18352
18600
  const { text, shown } = this.storedToolResultText(agentName, tc, maxChars);
18353
- spilled.set(tc.id, await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown));
18601
+ alone.set(tc.id, { text, shown, stored: await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown, bound) });
18602
+ }
18603
+ const share = maxChars === undefined ? undefined : toolResultShare(maxChars, bound, toolResults.length);
18604
+ const total = [...alone.values()].reduce((sum, { stored }) => sum + stored.text.length, 0);
18605
+ const byShare = share?.bound !== undefined && toolResults.length > 1
18606
+ && (this.toolRoundsByShare.has(toolResults) || total > bound);
18607
+ for (const tc of toolResults) {
18608
+ const { text, shown, stored } = alone.get(tc.id);
18609
+ spilled.set(tc.id, byShare
18610
+ ? await this.spillOrTruncate(text, share.cap, `${dateLabel}-${tc.id}`, shown, share.bound, stored.written)
18611
+ : stored);
18354
18612
  }
18355
18613
  const blocks = toolResults.map(tc => ({
18356
18614
  type: 'tool_result',
@@ -18361,7 +18619,10 @@ export class AgentFramework {
18361
18619
  content: spilled.get(tc.id).text,
18362
18620
  isError: tc.result.isError,
18363
18621
  }));
18364
- return { blocks, spilled };
18622
+ // A withheld round's stubs carry the guard's notice and place beside the
18623
+ // fields they keep, so under a bound their fields are cut within the
18624
+ // share, whether or not the round's results were stored by it.
18625
+ return { blocks, spilled, keptCap: share?.keptCap ?? maxChars };
18365
18626
  }
18366
18627
  /**
18367
18628
  * A tool result's text as its stored copy is written before any cut, and
@@ -18380,7 +18641,7 @@ export class AgentFramework {
18380
18641
  */
18381
18642
  storedToolResultText(agentName, tc, maxChars) {
18382
18643
  if (tc.result.isError)
18383
- return { text: tc.result.error ?? 'Unknown error', shown: null };
18644
+ return { text: (tc.result.error ?? 'Unknown error').toWellFormed(), shown: null };
18384
18645
  const ledger = this.toolImageLedgerFor(agentName);
18385
18646
  let blocks = null;
18386
18647
  const text = nonErrorResultText(tc.result, undefined, {
@@ -18530,21 +18791,30 @@ export class AgentFramework {
18530
18791
  return { path, error: failure };
18531
18792
  }
18532
18793
  /** How to read a tool result's file back a page at a time, with each page
18533
- * sized to come back within `cap`. */
18534
- spillPageAdvice(path, cap) {
18794
+ * sized to come back within `cap`, and, where `canRaise`, that raising
18795
+ * the inline cap would show more: where a cap at the readers' bound would
18796
+ * keep a longer head, or the whole result. */
18797
+ spillPageAdvice(path, cap, canRaise) {
18535
18798
  // JSON serialization can expand each read-back code unit to six
18536
18799
  // chars (\\u0000). Leave room for the page's metadata and a possible
18537
18800
  // extra code unit at a surrogate-pair boundary.
18538
18801
  const pageOverhead = JSON.stringify(path).length + 256;
18539
18802
  const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
18540
18803
  return `Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
18541
- + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
18542
- + 'agent_settings update tool_result_inline_max_chars.';
18804
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars.'
18805
+ + (canRaise ? ' Or raise the inline cap via agent_settings update tool_result_inline_max_chars.' : '');
18543
18806
  }
18544
18807
  async spillOrTruncate(content, cap, label,
18545
18808
  /** What the live copy showed beside this text (storedToolResultText);
18546
18809
  * null when the live copy was this text. */
18547
- shown = null) {
18810
+ shown = null,
18811
+ /** The most a reader's strategy keeps of this copy: the readers'
18812
+ * smallest per-message bound, or the result's share of it
18813
+ * (buildStoredToolResultContent). */
18814
+ bound = undefined,
18815
+ /** The spill file an earlier cut of this text wrote: reused, not written
18816
+ * again. Undefined when none was cut. */
18817
+ written) {
18548
18818
  if (!cap || content.length <= cap)
18549
18819
  return { text: content, filePath: null };
18550
18820
  // The stored text is the only place save_recent_image discovers tool
@@ -18553,19 +18823,24 @@ export class AgentFramework {
18553
18823
  // saw that the index would silently slide past (issue #104), so the
18554
18824
  // newest slots past the cut that the live copy showed are re-appended
18555
18825
  // after the notice. Otherwise the live copy is this text, and nothing
18556
- // past the cut is kept (#403).
18557
- const cut = cutStoredToolResult(content, cap, shown);
18826
+ // past the cut is kept (#403). The stored copy, notice and kept slots
18827
+ // included, fits `bound`, the most a reader's strategy keeps of it, so
18828
+ // no reader's cut takes the notice (cutStoredToolResultToFit), and the
18829
+ // spill notices name the head they kept.
18830
+ const cut = cutStoredToolResultToFit(content, cap, shown, bound);
18558
18831
  const { head } = cut;
18559
18832
  const kept = formatKeptImageSlots(cut);
18560
- const written = await this.writeToolResultFile(label, content);
18833
+ if (written === undefined)
18834
+ written = await this.writeToolResultFile(label, content);
18561
18835
  if (written && written.error === undefined) {
18562
18836
  const path = written.path;
18563
18837
  return {
18564
18838
  text: head
18565
18839
  + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
18566
- + `. ${this.spillPageAdvice(path, cap)}]`
18840
+ + `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
18567
18841
  + kept,
18568
18842
  filePath: path,
18843
+ written,
18569
18844
  };
18570
18845
  }
18571
18846
  if (written) {
@@ -18578,6 +18853,7 @@ export class AgentFramework {
18578
18853
  + ` (${written.error}); content over the cap was not retained]`
18579
18854
  + kept,
18580
18855
  filePath: null,
18856
+ written,
18581
18857
  };
18582
18858
  }
18583
18859
  return {
@@ -18585,6 +18861,7 @@ export class AgentFramework {
18585
18861
  + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]'
18586
18862
  + kept,
18587
18863
  filePath: null,
18864
+ written,
18588
18865
  };
18589
18866
  }
18590
18867
  /**
@@ -18870,8 +19147,10 @@ export class AgentFramework {
18870
19147
  * the array is text-only (let JSON path handle it; saves a code path).
18871
19148
  * `maxChars`, when provided, caps each accompanying text block so an image
18872
19149
  * inlined alongside an enormous text payload can't blow the context. A
18873
- * block is cut as the stored copy is (toolResultCutAt), never inside a
18874
- * slot, so the stored copy can keep exactly the slots this showed (#403).
19150
+ * block is cut at the cap as the stored copy's cut at that cap is
19151
+ * (toolResultCutAt), never inside a slot, so the stored copy can keep
19152
+ * exactly the slots this showed (#403); under a readers' bound the stored
19153
+ * copy's cut can come earlier (cutStoredToolResultToFit).
18875
19154
  */
18876
19155
  tryNativeToolResultContent(data, maxChars,
18877
19156
  /** Spill file written by the history path — referenced from truncation
@@ -18893,7 +19172,8 @@ export class AgentFramework {
18893
19172
  if (b.type === 'text') {
18894
19173
  // A text that isn't a string shows as the converters' placeholder,
18895
19174
  // rather than sending the whole array to the JSON fallback.
18896
- let text = typeof b.text === 'string' ? b.text : NON_STRING_TEXT_PLACEHOLDER;
19175
+ // Well formed, as the stored copy's text is (nonErrorResultText).
19176
+ let text = typeof b.text === 'string' ? b.text.toWellFormed() : NON_STRING_TEXT_PLACEHOLDER;
18897
19177
  if (maxChars && text.length > maxChars) {
18898
19178
  text = text.slice(0, toolResultCutAt(text, maxChars))
18899
19179
  + '\n\n[truncated — original was ' + text.length + ' chars'
@@ -18949,6 +19229,10 @@ export class AgentFramework {
18949
19229
  * (Sol's #94 ruling: a durable preference must not be a durable path for
18950
19230
  * one tool result to exceed maxMessageTokens; the way to see the whole
18951
19231
  * result is the spill file, not an over-bound blob in live context).
19232
+ * An ordinary resident's results land in the shared slot, which each of
19233
+ * its readers compiles under its own strategy, so the bound is the
19234
+ * smallest of theirs, as the tune-out dump's is (residentMessageChars); a
19235
+ * fork's or an ephemeral agent's store has itself as its only reader.
18952
19236
  */
18953
19237
  resolveToolResultInlineCap(agent) {
18954
19238
  const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
@@ -18957,12 +19241,25 @@ export class AgentFramework {
18957
19241
  const source = override !== undefined
18958
19242
  ? 'agent-settings-override'
18959
19243
  : configured !== null ? 'framework-config' : 'default';
18960
- const strategyBound = this.strategyDerivedToolResultChars(agent);
18961
- const clamped = strategyBound !== undefined && strategyBound < desired;
19244
+ let bound = this.strategyDerivedToolResultChars(agent);
19245
+ let byReader = false;
19246
+ if (this.isOrdinaryResident(agent)) {
19247
+ for (const reader of this.sharedSlotAgents) {
19248
+ if (reader === agent)
19249
+ continue;
19250
+ const readerBound = this.strategyDerivedToolResultChars(reader);
19251
+ if (readerBound !== undefined && (bound === undefined || readerBound < bound)) {
19252
+ bound = readerBound;
19253
+ byReader = true;
19254
+ }
19255
+ }
19256
+ }
19257
+ const clamped = bound !== undefined && bound < desired;
18962
19258
  return {
18963
- cap: clamped ? strategyBound : desired,
19259
+ cap: clamped ? bound : desired,
18964
19260
  source,
18965
- clampedBy: clamped ? 'strategy-bound' : null,
19261
+ clampedBy: clamped ? (byReader ? 'reader-bound' : 'strategy-bound') : null,
19262
+ bound,
18966
19263
  };
18967
19264
  }
18968
19265
  /**
@@ -18970,8 +19267,9 @@ export class AgentFramework {
18970
19267
  * its caller whole. A call from a running script resolves to the script,
18971
19268
  * which takes any result that fits its memory (scriptResultMaxChars). Any
18972
19269
  * other call's result is stored in its agent's context under the agent's
18973
- * inline cap, and a call for an agent the framework doesn't hold gets the
18974
- * house default.
19270
+ * inline cap, unless its round's results together pass the readers' bound,
19271
+ * when each takes its share (buildStoredToolResultContent), and a call for
19272
+ * an agent the framework doesn't hold gets the house default.
18975
19273
  */
18976
19274
  resultMaxCharsFor(agentName, callId) {
18977
19275
  const ce = this.codeExecution;
@@ -19544,8 +19842,9 @@ export class AgentFramework {
19544
19842
  // messages flush at the next turn's START (before its compile — riding
19545
19843
  // their own wake), at the agent's next tool boundary (where they are
19546
19844
  // ALSO injected into the live stream — hear-while-acting — all but a
19547
- // message a tune-out holds from the residents), or in driveStream's
19548
- // finally when the turn ends.
19845
+ // message a tune-out holds from the residents, and one the next compile
19846
+ // won't send among its newest), or in driveStream's finally when the
19847
+ // turn ends.
19549
19848
  // Quiesce (issue #122): the window exists to run compression/refold
19550
19849
  // against a FROZEN context — module events and api message.send must not
19551
19850
  // append mid-surgery. Deferred here, flushed by resume(). tool_result
@@ -19614,6 +19913,13 @@ export class AgentFramework {
19614
19913
  }
19615
19914
  return ''; // Deferred — flushed at the target's next boundary
19616
19915
  }
19916
+ // A clock row due goes first (storeDueClock). A write that gets here is a
19917
+ // tool result (which takes none), or no turn of the agent's is alive, or
19918
+ // its turn is assembling before its compile, or it comes from stop()'s
19919
+ // drain, once every stream it could join has settled: the clock is in
19920
+ // whatever request comes next.
19921
+ if (takesClockBefore(participant, content, metadata, agent.name))
19922
+ this.storeDueClock(agent, 'event');
19617
19923
  const stored = agent.getContextManager().addMessage(participant, content, opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata);
19618
19924
  if (opts?.placement) {
19619
19925
  opts.placement.agent = agent.name;
@@ -19696,6 +20002,72 @@ export class AgentFramework {
19696
20002
  if (this.quiesced || this.deferredWritesPersisted)
19697
20003
  this.persistDeferredWrites();
19698
20004
  }
20005
+ /**
20006
+ * What the next compile sends for each of `ids`, messages a tool boundary
20007
+ * has just stored, as the agent's strategy renders them as the newest of
20008
+ * its window (ContextManager.renderAsNewest): cut by the per-message cap,
20009
+ * a sharded body joined, and the image limits applied over the batch, so
20010
+ * the live stream hears what the next compile sends. An id comes back as
20011
+ * null where that compile won't send the message among its newest.
20012
+ * Undefined where the strategy has no such hook, or the render threw,
20013
+ * which is logged: the boundary then injects what it stored, as it did
20014
+ * before strategies could say.
20015
+ */
20016
+ renderForInjection(agent, ids) {
20017
+ if (ids.length === 0)
20018
+ return undefined;
20019
+ try {
20020
+ return agent.getContextManager().renderAsNewest(ids);
20021
+ }
20022
+ catch (error) {
20023
+ 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);
20024
+ return undefined;
20025
+ }
20026
+ }
20027
+ /**
20028
+ * Tell the agent of the messages a tool boundary stored and left out of
20029
+ * its injection, since the next compile won't send them among its newest:
20030
+ * who sent each and its size, and how to read it, `history--extract`
20031
+ * around it where the agent is shown that tool, else its id. None of a
20032
+ * message's words: the line stands in for a message the live round
20033
+ * doesn't carry. A system line, so it's no conversation: it neither
20034
+ * re-pins the turn nor clears its send suppression. It names the agent
20035
+ * it's for, since every resident sharing the slot reads it too. Returns
20036
+ * the notice as stored, for the boundary to inject after the batch, or
20037
+ * undefined when it couldn't be stored, which is logged.
20038
+ */
20039
+ recordLeftOutNotice(agent, leftOut) {
20040
+ const canExtract = this.advertisedToolsForAgent(agent.name).some((t) => t.name === 'history--extract');
20041
+ const one = leftOut.length === 1;
20042
+ const lines = leftOut.map(({ msg, id, content, parts }) => {
20043
+ const size = contentSize(content);
20044
+ // A sharded body is that many store messages, consecutive from its
20045
+ // first, which extract reads around it one by one. (Its "after" stops
20046
+ // at 100, so a body of more parts, over 300,000 tokens at the memory
20047
+ // strategy's default shard size, is read only that far.)
20048
+ const read = canExtract
20049
+ ? `history--extract with aroundId "${id}", before 0 and after ${parts - 1} reads it` +
20050
+ `${parts > 1 ? `, stored as ${parts} parts` : ''}.`
20051
+ : `Its id is ${id}${parts > 1 ? `, the first of the ${parts} parts it's stored as` : ''}.`;
20052
+ return `- From ${msg.participant}${size ? `, ${size}` : ''}. ${read}`;
20053
+ });
20054
+ const content = [{
20055
+ type: 'text',
20056
+ text: `[not shown] For ${agent.name}: ${one ? 'A message' : `${leftOut.length} messages`} arrived during your turn that ` +
20057
+ `${one ? "isn't" : "aren't"} shown here, since your context doesn't keep ${one ? 'it' : 'them'} among ` +
20058
+ `your newest messages:\n${lines.join('\n')}`,
20059
+ }];
20060
+ const metadata = { system: true, kind: 'left-out-notice' };
20061
+ try {
20062
+ const id = agent.getContextManager().addMessage('user', content, metadata);
20063
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'left-out-notice' });
20064
+ return { participant: 'user', content, metadata };
20065
+ }
20066
+ catch (err) {
20067
+ console.error(`[injection] ${agent.name}: couldn't store the notice of messages left out of the live round:`, err);
20068
+ return undefined;
20069
+ }
20070
+ }
19699
20071
  /**
19700
20072
  * Write drained entries in queue (seq) order, whatever their targets:
19701
20073
  * residents share one message slot, so the order entries arrived in is the
@@ -19814,7 +20186,7 @@ export class AgentFramework {
19814
20186
  continue;
19815
20187
  }
19816
20188
  if (found.length > 0) {
19817
- written.set(msg, { id: found[0].id, content: asCompiled(found) });
20189
+ written.set(msg, { id: found[0].id, content: asCompiled(found), parts: found.length });
19818
20190
  this.logDeferredWriteError(msg, where, undefined, { count: found.length, sharded: found[0].bodyGroupId !== undefined, foundLater: true });
19819
20191
  onLanded?.(msg, found[0].id);
19820
20192
  continue;
@@ -19831,7 +20203,7 @@ export class AgentFramework {
19831
20203
  const copies = agent && before !== undefined && (this.storedCountOf(agent) ?? before) - before > 1
19832
20204
  ? this.storedCopiesOf(agent, before, msg.id) ?? []
19833
20205
  : [];
19834
- written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content });
20206
+ written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content, parts: Math.max(1, copies.length) });
19835
20207
  }
19836
20208
  catch (error) {
19837
20209
  // What landed, read back from where the store stood before the
@@ -19839,7 +20211,7 @@ export class AgentFramework {
19839
20211
  // after it. With no agent there's no store it could have landed in.
19840
20212
  const landed = !agent ? [] : before === undefined ? undefined : this.storedCopiesOf(agent, before, msg.id);
19841
20213
  if (landed && landed.length > 0) {
19842
- written.set(msg, { id: landed[0].id, content: asCompiled(landed) });
20214
+ written.set(msg, { id: landed[0].id, content: asCompiled(landed), parts: landed.length });
19843
20215
  this.logDeferredWriteError(msg, where, error, { count: landed.length, sharded: landed[0].bodyGroupId !== undefined });
19844
20216
  onLanded?.(msg, landed[0].id);
19845
20217
  continue;
@@ -19880,14 +20252,19 @@ export class AgentFramework {
19880
20252
  * `bodyGroupId`. Undefined when the store can't be read back, which is
19881
20253
  * logged: not the same as nothing found. */
19882
20254
  storedCopiesOf(agent, from, deferredWriteId) {
20255
+ return this.storedSince(agent, from, (m) => m.metadata?.deferredWriteId === deferredWriteId, 'deferral', `deferred write ${deferredWriteId}`);
20256
+ }
20257
+ /** The messages `match` picks among those the agent's store gained from
20258
+ * position `from` on, in store order, with their blobs resolved. Undefined
20259
+ * when the store can't be read back, which is logged under `tag`, naming
20260
+ * `what` was written: not the same as nothing found. */
20261
+ storedSince(agent, from, match, tag, what) {
19883
20262
  try {
19884
20263
  const cm = agent.getContextManager();
19885
- return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages
19886
- .filter((m) => m.metadata?.deferredWriteId === deferredWriteId)
19887
- .map(storedCopy);
20264
+ return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages.filter(match).map(storedCopy);
19888
20265
  }
19889
20266
  catch (error) {
19890
- console.error(`[deferral] could not read back what deferred write ${deferredWriteId} stored in ${agent.name}'s store:`, error);
20267
+ console.error(`[${tag}] could not read back what ${what} stored in ${agent.name}'s store:`, error);
19891
20268
  return undefined;
19892
20269
  }
19893
20270
  }
@@ -22799,6 +23176,9 @@ export class AgentFramework {
22799
23176
  // configured servers can still deploy its first one at runtime.
22800
23177
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
22801
23178
  await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
23179
+ // The lifecycle log's replay restored any tune-out on record, and no
23180
+ // coordinator is made here to carry one.
23181
+ this.endTuneOutsWithoutCoordinator();
22802
23182
  }
22803
23183
  const collision = this.mcplPrefixCollision(config, undefined, claim);
22804
23184
  if (collision)
@@ -24468,6 +24848,47 @@ export class AgentFramework {
24468
24848
  ...structured,
24469
24849
  };
24470
24850
  }
24851
+ /**
24852
+ * A tune-out on record where this host has no tune-out coordinator: a
24853
+ * start with no subconscious makes none, and neither does a channel
24854
+ * subsystem brought up after start by connectMcplServer. Nothing then
24855
+ * diverts the channel's traffic or ever cancels its tune-out, tune_out
24856
+ * isn't offered, and channel_open, channel_close and channel_decline
24857
+ * refuse a tuned-out channel, so the channel would stay held by a decision
24858
+ * nothing here can change. Each one ends here instead, its channel decided
24859
+ * open again as a cancel leaves it, under the tune-out's own source, so an
24860
+ * agent's tune-out keeps the agent's provenance (the housekeeping guard
24861
+ * reads it). A log line names the channel and says how many of the
24862
+ * messages it diverted are held here, stored or still queued to be
24863
+ * written, which nothing will now deliver as its backlog.
24864
+ */
24865
+ endTuneOutsWithoutCoordinator() {
24866
+ const registry = this.channelRegistry;
24867
+ if (!registry || this.tuneOutCoordinator)
24868
+ return;
24869
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
24870
+ const ofEpoch = (epochId) => (m) => m.metadata?.tuneOut?.epochId === epochId;
24871
+ for (const { serverId, channelId, params, source } of registry.tuneOutsOnRecord()) {
24872
+ const diverted = typeof params.epochId !== 'string' ? 0
24873
+ : (primary?.getContextManager().getAllMessages() ?? []).filter(ofEpoch(params.epochId)).length +
24874
+ this.deferredMessages.filter((m) => (m.forAgent ?? this.primaryAgentName) === this.primaryAgentName)
24875
+ .filter(ofEpoch(params.epochId)).length;
24876
+ const held = diverted === 0 ? ''
24877
+ : diverted === 1 ? ' The 1 message it diverted stays here, and nothing will deliver it as its backlog.'
24878
+ : ` The ${diverted} messages it diverted stay here, and nothing will deliver them as its backlog.`;
24879
+ try {
24880
+ registry.cancelTuneOut(serverId, channelId, 'open', source);
24881
+ }
24882
+ catch (err) {
24883
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server is tuned out, and nothing on this host watches ` +
24884
+ `or cancels tune-outs, but ending this one failed, so it stays recorded until a start that can end it: ` +
24885
+ `${err instanceof Error ? err.message : String(err)}.${held}`);
24886
+ continue;
24887
+ }
24888
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server was tuned out, and nothing on this host watches ` +
24889
+ `or cancels tune-outs, so the tune-out ends here and the channel is decided open again.${held}`);
24890
+ }
24891
+ }
24471
24892
  /**
24472
24893
  * Build BeforeInferenceParams from agent state and trigger context.
24473
24894
  */
@@ -25262,11 +25683,24 @@ export class AgentFramework {
25262
25683
  // carries the same ref.
25263
25684
  const state = agent.state;
25264
25685
  if (state.status === 'waiting_for_tools') {
25265
- const cap = this.resolveToolResultInlineCap(agent).cap;
25686
+ // The commit stores a round as each result would be alone, or by
25687
+ // its share where they pass the bound together, which a sibling
25688
+ // still running can decide. Where the two ways differ for a result
25689
+ // counted here, the round is marked to be stored by its share
25690
+ // (toolRoundsByShare), so the count holds either way: a result
25691
+ // counted before the mark is the same both ways. The share is of
25692
+ // the round's calls, finished or not.
25693
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
25694
+ const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
25266
25695
  for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
25267
25696
  const tc = state.completed[i];
25268
25697
  const { text, shown } = this.storedToolResultText(agentName, tc, cap);
25269
- considerToolResultImages(tc.id, tc.name, storedImageSlots(text, cap, shown), 0);
25698
+ const alone = storedImageSlots(text, cap, shown, bound);
25699
+ const byShare = storedImageSlots(text, share.cap, shown, share.bound);
25700
+ if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j].text)) {
25701
+ this.toolRoundsByShare.add(state.completed);
25702
+ }
25703
+ considerToolResultImages(tc.id, tc.name, this.toolRoundsByShare.has(state.completed) ? byShare : alone, 0);
25270
25704
  }
25271
25705
  }
25272
25706
  // Walk the message store tail-first in bounded windows, re-inlining