@animalabs/agent-framework 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/src/agent.d.ts +5 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +6 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/clock-rows.d.ts +115 -0
  6. package/dist/src/clock-rows.d.ts.map +1 -0
  7. package/dist/src/clock-rows.js +222 -0
  8. package/dist/src/clock-rows.js.map +1 -0
  9. package/dist/src/framework.d.ts +95 -5
  10. package/dist/src/framework.d.ts.map +1 -1
  11. package/dist/src/framework.js +485 -74
  12. package/dist/src/framework.js.map +1 -1
  13. package/dist/src/index.d.ts +2 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/channel-registry.d.ts +9 -0
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +49 -6
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/protocol-family.js +1 -1
  22. package/dist/src/modules/workspace/index.d.ts +9 -1
  23. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  24. package/dist/src/modules/workspace/index.js +44 -8
  25. package/dist/src/modules/workspace/index.js.map +1 -1
  26. package/dist/src/timezone.d.ts +4 -2
  27. package/dist/src/timezone.d.ts.map +1 -1
  28. package/dist/src/timezone.js +4 -2
  29. package/dist/src/timezone.js.map +1 -1
  30. package/dist/src/tool-image-ledger.d.ts +46 -7
  31. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  32. package/dist/src/tool-image-ledger.js +60 -7
  33. package/dist/src/tool-image-ledger.js.map +1 -1
  34. package/dist/src/tool-result-guard.d.ts +10 -8
  35. package/dist/src/tool-result-guard.d.ts.map +1 -1
  36. package/dist/src/tool-result-guard.js +12 -10
  37. package/dist/src/tool-result-guard.js.map +1 -1
  38. package/dist/src/tool-result-history.d.ts +2 -1
  39. package/dist/src/tool-result-history.d.ts.map +1 -1
  40. package/dist/src/tool-result-history.js +2 -1
  41. package/dist/src/tool-result-history.js.map +1 -1
  42. package/dist/src/tune-out/coordinator.js +3 -3
  43. package/dist/src/tune-out/coordinator.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +27 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/events.d.ts +5 -1
  47. package/dist/src/types/events.d.ts.map +1 -1
  48. package/dist/src/types/framework.d.ts +7 -4
  49. package/dist/src/types/framework.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/agent.ts +6 -0
  52. package/src/clock-rows.ts +302 -0
  53. package/src/framework.ts +508 -79
  54. package/src/index.ts +2 -0
  55. package/src/mcpl/channel-registry.ts +54 -8
  56. package/src/mcpl/protocol-family.ts +1 -1
  57. package/src/modules/workspace/index.ts +47 -9
  58. package/src/timezone.ts +4 -2
  59. package/src/tool-image-ledger.ts +62 -8
  60. package/src/tool-result-guard.ts +12 -10
  61. package/src/tool-result-history.ts +2 -1
  62. package/src/tune-out/coordinator.ts +3 -3
  63. package/src/types/agent.ts +28 -0
  64. package/src/types/events.ts +5 -1
  65. package/src/types/framework.ts +7 -4
@@ -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';
@@ -294,9 +294,12 @@ function malformedResponseContent(response) {
294
294
  * mid-turn message's text at chars/4 and its other blocks about as
295
295
  * context-manager's store prices them (injectedBlockTokens), and a small
296
296
  * 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
297
+ * message rides into the next round as the boundary injects it, which is as
298
+ * the strategy renders it for the next compile, cut and with its images
299
+ * limited (renderForInjection), or its stored content where the strategy
300
+ * can't say (provideToolResults' injectedMessages). Its XML tool path declares that it
301
+ * can't carry injected messages, so the tool boundary injects none there and
302
+ * that term is zero. Deliberately a
300
303
  * ceiling-ish heuristic, but for a mid-turn message's media at the store's
301
304
  * flat prices — the cost of overestimating is one early recompile;
302
305
  * the cost of underestimating is the 400 this projection exists to prevent.
@@ -533,6 +536,7 @@ import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/to
533
536
  import { boundedForLog, describeCause } from './mcpl/errors.js';
534
537
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
535
538
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
539
+ import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
536
540
  import { firstFreeBranchName } from './branch-names.js';
537
541
  import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, DiscordAwarenessRefusal, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, selectDiscordAwarenessRefs, checkedDiscordAwarenessMarks, boundDiscordAwarenessText, discordChannelOf, isPermanentDiscordReactionFailure, unsyncedWriteText, } from './recovery/discord-awareness-outbox.js';
538
542
  import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } from './operator-log.js';
@@ -572,6 +576,25 @@ function asCompiled(landed) {
572
576
  const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('');
573
577
  return [...blocks.filter((b) => b.type !== 'text'), { type: 'text', text }];
574
578
  }
579
+ /** A message's size as a notice gives it: its text's characters and its
580
+ * images, each left out when there are none, so empty when it has
581
+ * neither. */
582
+ function contentSize(content) {
583
+ let chars = 0;
584
+ let images = 0;
585
+ for (const b of content) {
586
+ if (b.type === 'text')
587
+ chars += b.text.length;
588
+ else if (b.type === 'image' || b.type === 'generated_image')
589
+ images++;
590
+ }
591
+ const parts = [];
592
+ if (chars > 0)
593
+ parts.push(`${chars.toLocaleString('en-US')} character${chars === 1 ? '' : 's'}`);
594
+ if (images > 0)
595
+ parts.push(`${images} image${images === 1 ? '' : 's'}`);
596
+ return parts.join(' and ');
597
+ }
575
598
  const storedCopy = (m) => ({ id: m.id, content: m.content, ...(m.bodyGroupId !== undefined ? { bodyGroupId: m.bodyGroupId } : {}) });
576
599
  /** Stamp a deferred write's durable id into the metadata it is stored with
577
600
  * (boot recovery dedups replays by it). Idempotent for an already-stamped
@@ -1983,6 +2006,10 @@ export class AgentFramework {
1983
2006
  * ephemeral agents have isolated slots and are their own only readers.
1984
2007
  */
1985
2008
  sharedSlotAgents = new Set();
2009
+ /** Rounds of tool calls, by their results array, that a save_recent_image
2010
+ * in the round counted by their share of the bound: such a round is
2011
+ * stored by its share whether or not it fits (buildStoredToolResultContent). */
2012
+ toolRoundsByShare = new WeakSet();
1986
2013
  /** The windows of forks and runs deregistered while the host was quiesced,
1987
2014
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
1988
2015
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -2630,6 +2657,9 @@ export class AgentFramework {
2630
2657
  });
2631
2658
  framework.tuneOutCoordinator.resumeActiveEpochs();
2632
2659
  }
2660
+ else {
2661
+ framework.endTuneOutsWithoutCoordinator();
2662
+ }
2633
2663
  // Modules, agents, MCPL servers and the tune-out tool are all in place:
2634
2664
  // the first point at which "matches no tool" can be judged. Later tool
2635
2665
  // refreshes re-check patterns still undecided.
@@ -9969,6 +9999,10 @@ export class AgentFramework {
9969
9999
  // Speech happens only via speak_in_channel (its own marked voice).
9970
10000
  proseRouting: 'explicit',
9971
10001
  allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'],
10002
+ // No clock rows of its own: its merged view carries the residents'
10003
+ // clocks, and clocks it planned from its own rows alone would read the
10004
+ // time a second time beside them, or disagree with them.
10005
+ clock: undefined,
9972
10006
  };
9973
10007
  const agent = new Agent(agentConfig, contextManager, this.membrane);
9974
10008
  this.adoptToolResultGuard(agent);
@@ -10115,14 +10149,14 @@ export class AgentFramework {
10115
10149
  if (currentState.status === 'ready') {
10116
10150
  // Effective inline cap: agent_settings override → durable config →
10117
10151
  // house default, clamped to the strategy bound (see resolver).
10118
- const maxChars = this.resolveToolResultInlineCap(agent).cap;
10152
+ const { cap: maxChars, bound } = this.resolveToolResultInlineCap(agent);
10119
10153
  // Oversized results — successes AND errors — spill to a workspace
10120
10154
  // file (truncated preview + file reference) instead of being
10121
10155
  // blind-truncated. Computed ONCE per call and reused for both the
10122
10156
  // history copy and the live wire copy below — the two must stay
10123
10157
  // byte-matched (the window stores what the membrane sends;
10124
10158
  // divergence breaks the compile prefix).
10125
- const { blocks: toolResultContent, spilled } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars);
10159
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(agent.name, currentState.toolResults, maxChars, bound);
10126
10160
  // A spill writes to the workspace, and the turn can be stopped
10127
10161
  // meanwhile. A host's cancelStream, stop() and abortInference cancel
10128
10162
  // the round's stream at `ready` and leave the agent idle, and an
@@ -10172,7 +10206,7 @@ export class AgentFramework {
10172
10206
  this.pendingAssistantBlocks.delete(agent.name);
10173
10207
  }
10174
10208
  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);
10209
+ agent.toolResultGuard.storeResults(toolResultContent, membraneResults, currentState.toolResults, turnRowMetadata, keptCap);
10176
10210
  // Flush any messages that were deferred while this turn was in
10177
10211
  // flight: this agent's own entries, those addressed to it and, for
10178
10212
  // the primary, those addressed to no one (module messages, push
@@ -10184,14 +10218,21 @@ export class AgentFramework {
10184
10218
  // live turn sees them instead of the agent staying deaf until the
10185
10219
  // turn ends. The window stores them here, right after the
10186
10220
  // 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).
10221
+ // them in the live conversation — same participant, and neither
10222
+ // carries the row's metadata — and each goes in as the next compile
10223
+ // will send it, which the strategy renders (renderForInjection): cut
10224
+ // by the per-message cap, a sharded body joined, and its images as
10225
+ // the image limits leave them at the newest position. So the next
10226
+ // turn's compiled prefix matches the live request (prompt-cache
10227
+ // safe), up to what came after them: a later message can still
10228
+ // take an earlier one past an image limit, as for any message, and
10229
+ // the next compile then strips that image where the live rounds
10230
+ // carried it. One the next compile won't send among its newest
10231
+ // isn't injected; a notice after the batch tells the agent of it
10232
+ // (recordLeftOutNotice). A write that threw after it was stored, whose landing
10233
+ // couldn't be read, is the exception: it's carried at the flush
10234
+ // that finds it, after what was stored since, so there the store and
10235
+ // the live request differ (writeDrainedInOrder).
10195
10236
  //
10196
10237
  // The queue is drained ONLY at the target agent's own boundary:
10197
10238
  // draining at another agent's boundary would store the messages
@@ -10226,21 +10267,29 @@ export class AgentFramework {
10226
10267
  // a held message does, rather than sitting in the window between
10227
10268
  // rounds the stream never showed it.
10228
10269
  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();
10270
+ // What this boundary stored for the agent to hear, in store order,
10271
+ // injected or left out, for a boundary that leaves it to the turn's
10272
+ // next start as arrivals: that start's compile sends it.
10273
+ const heard = [];
10232
10274
  if (currentState.supportsInjectedMessages !== false) {
10275
+ // A clock row due (storeDueClock) goes right after the round's
10276
+ // results, ahead of what is drained below, and joins the stream
10277
+ // the same way. A boundary that passed during the round can't
10278
+ // stand between a tool's call and its results, so this clock
10279
+ // reads the time it's written (clock-rows.ts).
10280
+ const clock = this.storeDueClock(agent, 'tool-boundary');
10233
10281
  const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
10234
10282
  // Stored in queue order, cut at the first that can't be stored: it
10235
10283
  // and what follows wait for the turn's end, and only what was
10236
10284
  // stored is injected (the round's results go on either way). One
10237
10285
  // 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).
10286
+ // follows goes on. What was stored is injected as the next compile
10287
+ // will send it (renderForInjection). Where the strategy can't say,
10288
+ // a sharded body read back from its store is injected as later
10289
+ // compiles join it, and a write that threw after storing, which
10290
+ // counts as stored, as what landed of it (writeDrainedInOrder).
10242
10291
  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) {
10292
+ for (const [msg, { id, content, parts }] of stored) {
10244
10293
  // Every other resident reads what this boundary stores; for its
10245
10294
  // own agent, the boundary weighs what it injects below.
10246
10295
  this.noteForOtherResidents(agent, msg.metadata, id);
@@ -10250,13 +10299,49 @@ export class AgentFramework {
10250
10299
  // what the next compile hides (#232 gap 5).
10251
10300
  if (msg.metadata?.tuneOut)
10252
10301
  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);
10302
+ heard.push({ msg, id, content, parts });
10303
+ }
10304
+ // Rendered in one call: a render prepares as a compile does, so
10305
+ // the batch shares one preparation. The image limits count from
10306
+ // the batch's oldest message to the newest, as a compile does.
10307
+ // The clock, stored ahead of them, renders first in the same batch
10308
+ // and is injected first, as the next compile sends it.
10309
+ const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
10310
+ const first = clock ? 1 : 0;
10311
+ if (clock) {
10312
+ const sent = rendered ? rendered[0] : clock.content;
10313
+ if (sent !== null) {
10314
+ midTurnInjections.push({ participant: 'user', content: sent, metadata: clock.metadata });
10315
+ }
10316
+ else {
10317
+ 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`);
10318
+ }
10319
+ }
10320
+ const leftOut = [];
10321
+ heard.forEach((h, i) => {
10322
+ const sent = rendered ? rendered[i + first] : h.content;
10323
+ // The next compile won't send it among its newest: injected, it
10324
+ // would put in the live turn what that compile sends elsewhere,
10325
+ // or not at all. It's under any consumed mark the boundary sets
10326
+ // (below), as anything stored here is.
10327
+ if (sent === null) {
10328
+ leftOut.push(h);
10329
+ return;
10330
+ }
10331
+ midTurnInjections.push({
10332
+ participant: h.msg.participant,
10333
+ content: sent,
10334
+ ...(h.msg.metadata ? { metadata: h.msg.metadata } : {}),
10335
+ });
10336
+ });
10337
+ if (leftOut.length > 0) {
10338
+ 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(', ')}`);
10339
+ // The agent is told what it didn't hear, after the batch, as
10340
+ // the routing notice is: stored, so the next compile sends it
10341
+ // where the live round heard it.
10342
+ const notice = this.recordLeftOutNotice(agent, leftOut);
10343
+ if (notice)
10344
+ midTurnInjections.push(notice);
10260
10345
  }
10261
10346
  }
10262
10347
  // Deferred writes handed to the store above: sync, then drop them
@@ -10391,11 +10476,12 @@ export class AgentFramework {
10391
10476
  }
10392
10477
  else if (routesLocus && !shouldEndTurn) {
10393
10478
  // 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));
10479
+ // stream to tell) leaves what it stored for the agent to the
10480
+ // turn's next start, as arrivals: a budget restart, or a
10481
+ // re-inference on these tool results. That includes a message
10482
+ // left out of the injection, since that start compiles anew.
10483
+ for (const { msg, id } of heard)
10484
+ this.noteArrival(agent.name, msg.metadata, id);
10399
10485
  }
10400
10486
  if (shouldEndTurn) {
10401
10487
  // endTurn: what this boundary stored stays; cancel stream, reset to idle.
@@ -10496,6 +10582,18 @@ export class AgentFramework {
10496
10582
  // Under context-manager#160 such a write already gives the
10497
10583
  // replies stored after it stamps their requests didn't carry,
10498
10584
  // whatever the mark says; deferring those writers is the fix.
10585
+ // The mark also covers a message the boundary stored and left out
10586
+ // of the injection, since the next compile won't send it among its
10587
+ // newest: the notice telling the agent of it is injected, so a
10588
+ // boundary that leaves one out injects something, unless that
10589
+ // notice couldn't be stored. The message then counts as read
10590
+ // though no request carried it: a coalesced render's wake to read
10591
+ // it drops, and a release doesn't pass it to the primary. The
10592
+ // notice has told the agent it came, from whom, and how to read
10593
+ // it. Where the view leaves it out, no request will carry it.
10594
+ // Where the memory strategy places it before its recent window, a
10595
+ // batch bigger than that window, the next compile carries it, in
10596
+ // whatever turn something else starts.
10499
10597
  if (midTurnInjections.length > 0) {
10500
10598
  // Possibly shown, whatever the stream declares: the first-view
10501
10599
  // mark (CoalescerHost.isUnshown) counts a doubt as shown.
@@ -10774,6 +10872,11 @@ export class AgentFramework {
10774
10872
  return undefined;
10775
10873
  if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
10776
10874
  metadata.triggered = event.triggerInference ?? false;
10875
+ // A fork's channel messages are stored past its deferral; a clock goes
10876
+ // ahead of one only while the fork has no turn alive (storeDueClock).
10877
+ if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
10878
+ this.storeDueClock(target, 'event');
10879
+ }
10777
10880
  const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content));
10778
10881
  // A correction may target an older engagement. Refresh only the
10779
10882
  // binding whose agent actually received the message.
@@ -10933,6 +11036,10 @@ export class AgentFramework {
10933
11036
  // on top of the router's own trigger policy.
10934
11037
  const trigger = decision.trigger && event.triggerInference !== false;
10935
11038
  messageMetadata.triggered = trigger;
11039
+ // As for a correction above: a clock ahead only while no turn is alive.
11040
+ if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
11041
+ this.storeDueClock(agent, 'event');
11042
+ }
10936
11043
  const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
10937
11044
  router.touch(event.channelId);
10938
11045
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
@@ -13671,6 +13778,101 @@ export class AgentFramework {
13671
13778
  return m.channelId;
13672
13779
  return routing.engaged.has(m.channelId) ? m.channelId : undefined;
13673
13780
  }
13781
+ /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */
13782
+ turnAlive(agent) {
13783
+ return this.activeTurnTokens.has(agent.name)
13784
+ || this.pendingAssistantBlocks.has(agent.name)
13785
+ || this.activeStreams.has(agent.name);
13786
+ }
13787
+ /**
13788
+ * Store the clock row due in `agent`'s window (clock-rows.ts), if the agent
13789
+ * has clock rows and a boundary has passed since the window's last clock,
13790
+ * or the window holds no clock row yet (planClock), and return it. The
13791
+ * caller places it, by calling this right before what the clock precedes
13792
+ * is written: an incoming event while no turn of the agent's is alive (or
13793
+ * one is assembling before its compile), a fresh turn's inputs before its
13794
+ * compile, or, mid-turn, the messages a tool boundary injects, where the
13795
+ * caller injects the clock first. Each is a place where a row is either in
13796
+ * the request that follows or before every request still to come, so the
13797
+ * window and the live stream keep matching.
13798
+ *
13799
+ * None while the host is quiesced (the window is frozen for surgery), and
13800
+ * none in the residents' shared slot while another of its readers (the
13801
+ * subconscious among them, through its merged view) has a turn alive: the
13802
+ * row would sit inside that turn without joining its stream. A clock that
13803
+ * can't be planned or stored is logged and skipped: it never holds up what
13804
+ * it would have preceded. One whose write threw after it landed is the
13805
+ * window's all the same, so it's returned, and a tool boundary injects it.
13806
+ */
13807
+ storeDueClock(agent, occasion) {
13808
+ const config = agent.clock;
13809
+ if (!config || this.quiesced)
13810
+ return undefined;
13811
+ if (this.sharedSlotAgents.has(agent)) {
13812
+ for (const reader of this.sharedSlotAgents) {
13813
+ if (reader !== agent && this.turnAlive(reader))
13814
+ return undefined;
13815
+ }
13816
+ }
13817
+ let planned;
13818
+ try {
13819
+ planned = planClock({ now: Date.now(), ...this.lastClockOf(agent.getContextManager()), config, zone: this.timeZone });
13820
+ }
13821
+ catch (err) {
13822
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}:`, err);
13823
+ return undefined;
13824
+ }
13825
+ if (!planned)
13826
+ return undefined;
13827
+ const metadata = { system: true, kind: CLOCK_ROW_KIND, clock: planned.stamp };
13828
+ const before = this.storedCountOf(agent);
13829
+ let id;
13830
+ try {
13831
+ id = agent.getContextManager().addMessage('user', planned.content, metadata);
13832
+ }
13833
+ catch (err) {
13834
+ // A write can throw after its row landed, as when context-manager's own
13835
+ // propagation of it fails: what landed is read back from where the
13836
+ // store stood before, as writeDrainedInOrder reads a drained write's.
13837
+ const at = planned.stamp.at;
13838
+ const landed = before === undefined
13839
+ ? undefined
13840
+ : this.storedSince(agent, before, (m) => clockStampOf(m.metadata)?.at === at, 'clock', 'the clock write');
13841
+ if (!landed || landed.length === 0) {
13842
+ console.error(`[clock] ${agent.name}: no clock row at ${occasion}${landed ? '' : ' (whether it landed is unknown)'}:`, err);
13843
+ return undefined;
13844
+ }
13845
+ console.error(`[clock] ${agent.name}: the clock row at ${occasion} landed though its write threw:`, err);
13846
+ id = landed[0].id;
13847
+ }
13848
+ this.emitTrace({ type: 'message:added', messageId: id, source: `clock:${occasion}` });
13849
+ return { id, content: planned.content, metadata };
13850
+ }
13851
+ /** Rows a window's last-clock scan reads at a time. */
13852
+ static CLOCK_SCAN_ROWS = 256;
13853
+ /** What the next clock is planned from: the window's newest row's time and
13854
+ * the time its last clock row reads, found by reading back from the
13855
+ * newest row, CLOCK_SCAN_ROWS at a time, until a clock row comes, however
13856
+ * far back it is. In the steady state the newest chunk holds it, unless
13857
+ * more rows than that have come since the last clock; a window with no
13858
+ * clock row is read whole once, and then takes one (planClock). */
13859
+ lastClockOf(cm) {
13860
+ let newestRowAt;
13861
+ for (let end = cm.getMessageCount(); end > 0;) {
13862
+ const span = Math.min(end, AgentFramework.CLOCK_SCAN_ROWS);
13863
+ const { messages } = cm.getMessageWindow(end - span, span, { resolveBlobs: false });
13864
+ if (newestRowAt === undefined && messages.length > 0) {
13865
+ newestRowAt = new Date(messages[messages.length - 1].timestamp).getTime();
13866
+ }
13867
+ for (let i = messages.length - 1; i >= 0; i--) {
13868
+ const stamp = clockStampOf(messages[i].metadata);
13869
+ if (stamp)
13870
+ return { newestRowAt, lastClockAt: stamp.at };
13871
+ }
13872
+ end -= span;
13873
+ }
13874
+ return { newestRowAt, lastClockAt: undefined };
13875
+ }
13674
13876
  /**
13675
13877
  * Weigh a message that no tool boundary of the agent's open logical turn
13676
13878
  * will weigh: one that would re-pin the locus becomes one of the turn's
@@ -15133,6 +15335,17 @@ export class AgentFramework {
15133
15335
  // turn, and flushing there would insert messages between its rounds.
15134
15336
  // Before recordTurnCheckpoint: these are the turn's inputs, not its
15135
15337
  // products — undoing the turn must not destroy them.
15338
+ // A clock row due (storeDueClock) goes ahead of the turn's inputs, so a
15339
+ // turn starts with a current clock, a silent heartbeat's tick (which
15340
+ // stores no row of its own) included, unless storeDueClock writes none:
15341
+ // while the host is quiesced, while another reader of the residents'
15342
+ // shared slot has a turn alive, or when its write fails without landing.
15343
+ // Then a later event, turn start or tool boundary takes it. Fresh turns
15344
+ // only, as the flush below: a turn that resumes continues its logical
15345
+ // turn, which took its clock at its start or at a tool boundary that
15346
+ // could carry one.
15347
+ if (attempt === 0 && !resumesTurn)
15348
+ this.storeDueClock(agent, 'turn-start');
15136
15349
  if (attempt === 0 &&
15137
15350
  !resumesTurn &&
15138
15351
  this.deferredMessages.length > 0) {
@@ -16614,9 +16827,9 @@ export class AgentFramework {
16614
16827
  // one door a giant blob still walks through).
16615
16828
  const readyState = agent.state;
16616
16829
  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);
16830
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
16831
+ const { blocks: toolResultContent, spilled, keptCap } = await this.buildStoredToolResultContent(agent.name, readyState.toolResults, cap, bound);
16832
+ 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
16833
  // The response is already complete: this batch is never
16621
16834
  // submitted in this turn, so it cannot be refused. Admit it.
16622
16835
  agent.toolResultGuard.settleTurnEnded();
@@ -17852,7 +18065,8 @@ export class AgentFramework {
17852
18065
  // token (as during a real tool call) and lands AFTER the pair, never
17853
18066
  // between tool_use and its tool_result. The two addMessage calls
17854
18067
  // below are synchronous and adjacent — nothing can interleave.
17855
- const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], call.resultMaxChars);
18068
+ const { bound } = this.resolveToolResultInlineCap(agent);
18069
+ const { blocks } = await this.buildStoredToolResultContent(agentName, [{ id: toolUseId, name: toolName, input, result, durationMs }], call.resultMaxChars, bound);
17856
18070
  const toolUse = [
17857
18071
  { type: 'tool_use', id: toolUseId, name: toolName, input },
17858
18072
  ];
@@ -18294,10 +18508,12 @@ export class AgentFramework {
18294
18508
  if (!this.noticeTargetFor(agentName, `script ${scriptId} wake`))
18295
18509
  throw new Error(`agent ${agentName} has no context to wake`);
18296
18510
  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}`);
18511
+ // A reader's strategy cuts the wake's whole message, so its spill fits the
18512
+ // readers' bound with its notice.
18513
+ const { cap: maxChars, bound } = agent
18514
+ ? this.resolveToolResultInlineCap(agent)
18515
+ : { cap: DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, bound: undefined };
18516
+ const { text: shown } = await this.spillOrTruncate(text, maxChars, `script-${label}`, null, bound);
18301
18517
  // The spill can take a while: the agent may have gone since, or the host begun to stop.
18302
18518
  if (this.stopHasBegun())
18303
18519
  throw new Error('this host is stopping');
@@ -18345,12 +18561,32 @@ export class AgentFramework {
18345
18561
  * #89). Returns the stored blocks plus the per-call spill outcomes so the
18346
18562
  * live wire copy can reuse the identical strings.
18347
18563
  */
18348
- async buildStoredToolResultContent(agentName, toolResults, maxChars) {
18564
+ async buildStoredToolResultContent(agentName, toolResults, maxChars,
18565
+ /** The readers' smallest per-message bound (resolveToolResultInlineCap). */
18566
+ bound = undefined) {
18349
18567
  const spilled = new Map();
18350
18568
  const dateLabel = new Date().toISOString().slice(0, 10);
18569
+ // The results are stored as one message, whose bound a reader's
18570
+ // strategy spends across them in order. Each is stored as it would be
18571
+ // alone, unless together they pass the bound, or a save in the round
18572
+ // counted the round by its share (toolRoundsByShare): then each is
18573
+ // stored under its share (toolResultShare). A result carrying an image
18574
+ // keeps its live copy cut at the cap; any other's live copy is its stored
18575
+ // text.
18576
+ const alone = new Map();
18351
18577
  for (const tc of toolResults) {
18352
18578
  const { text, shown } = this.storedToolResultText(agentName, tc, maxChars);
18353
- spilled.set(tc.id, await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown));
18579
+ alone.set(tc.id, { text, shown, stored: await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown, bound) });
18580
+ }
18581
+ const share = maxChars === undefined ? undefined : toolResultShare(maxChars, bound, toolResults.length);
18582
+ const total = [...alone.values()].reduce((sum, { stored }) => sum + stored.text.length, 0);
18583
+ const byShare = share?.bound !== undefined && toolResults.length > 1
18584
+ && (this.toolRoundsByShare.has(toolResults) || total > bound);
18585
+ for (const tc of toolResults) {
18586
+ const { text, shown, stored } = alone.get(tc.id);
18587
+ spilled.set(tc.id, byShare
18588
+ ? await this.spillOrTruncate(text, share.cap, `${dateLabel}-${tc.id}`, shown, share.bound, stored.written)
18589
+ : stored);
18354
18590
  }
18355
18591
  const blocks = toolResults.map(tc => ({
18356
18592
  type: 'tool_result',
@@ -18361,7 +18597,10 @@ export class AgentFramework {
18361
18597
  content: spilled.get(tc.id).text,
18362
18598
  isError: tc.result.isError,
18363
18599
  }));
18364
- return { blocks, spilled };
18600
+ // A withheld round's stubs carry the guard's notice and place beside the
18601
+ // fields they keep, so under a bound their fields are cut within the
18602
+ // share, whether or not the round's results were stored by it.
18603
+ return { blocks, spilled, keptCap: share?.keptCap ?? maxChars };
18365
18604
  }
18366
18605
  /**
18367
18606
  * A tool result's text as its stored copy is written before any cut, and
@@ -18530,21 +18769,30 @@ export class AgentFramework {
18530
18769
  return { path, error: failure };
18531
18770
  }
18532
18771
  /** 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) {
18772
+ * sized to come back within `cap`, and, where `canRaise`, that raising
18773
+ * the inline cap would show more: where a cap at the readers' bound would
18774
+ * keep a longer head, or the whole result. */
18775
+ spillPageAdvice(path, cap, canRaise) {
18535
18776
  // JSON serialization can expand each read-back code unit to six
18536
18777
  // chars (\\u0000). Leave room for the page's metadata and a possible
18537
18778
  // extra code unit at a surrogate-pair boundary.
18538
18779
  const pageOverhead = JSON.stringify(path).length + 256;
18539
18780
  const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
18540
18781
  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.';
18782
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars.'
18783
+ + (canRaise ? ' Or raise the inline cap via agent_settings update tool_result_inline_max_chars.' : '');
18543
18784
  }
18544
18785
  async spillOrTruncate(content, cap, label,
18545
18786
  /** What the live copy showed beside this text (storedToolResultText);
18546
18787
  * null when the live copy was this text. */
18547
- shown = null) {
18788
+ shown = null,
18789
+ /** The most a reader's strategy keeps of this copy: the readers'
18790
+ * smallest per-message bound, or the result's share of it
18791
+ * (buildStoredToolResultContent). */
18792
+ bound = undefined,
18793
+ /** The spill file an earlier cut of this text wrote: reused, not written
18794
+ * again. Undefined when none was cut. */
18795
+ written) {
18548
18796
  if (!cap || content.length <= cap)
18549
18797
  return { text: content, filePath: null };
18550
18798
  // The stored text is the only place save_recent_image discovers tool
@@ -18553,19 +18801,24 @@ export class AgentFramework {
18553
18801
  // saw that the index would silently slide past (issue #104), so the
18554
18802
  // newest slots past the cut that the live copy showed are re-appended
18555
18803
  // 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);
18804
+ // past the cut is kept (#403). The stored copy, notice and kept slots
18805
+ // included, fits `bound`, the most a reader's strategy keeps of it, so
18806
+ // no reader's cut takes the notice (cutStoredToolResultToFit), and the
18807
+ // spill notices name the head they kept.
18808
+ const cut = cutStoredToolResultToFit(content, cap, shown, bound);
18558
18809
  const { head } = cut;
18559
18810
  const kept = formatKeptImageSlots(cut);
18560
- const written = await this.writeToolResultFile(label, content);
18811
+ if (written === undefined)
18812
+ written = await this.writeToolResultFile(label, content);
18561
18813
  if (written && written.error === undefined) {
18562
18814
  const path = written.path;
18563
18815
  return {
18564
18816
  text: head
18565
18817
  + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
18566
- + `. ${this.spillPageAdvice(path, cap)}]`
18818
+ + `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
18567
18819
  + kept,
18568
18820
  filePath: path,
18821
+ written,
18569
18822
  };
18570
18823
  }
18571
18824
  if (written) {
@@ -18578,6 +18831,7 @@ export class AgentFramework {
18578
18831
  + ` (${written.error}); content over the cap was not retained]`
18579
18832
  + kept,
18580
18833
  filePath: null,
18834
+ written,
18581
18835
  };
18582
18836
  }
18583
18837
  return {
@@ -18585,6 +18839,7 @@ export class AgentFramework {
18585
18839
  + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]'
18586
18840
  + kept,
18587
18841
  filePath: null,
18842
+ written,
18588
18843
  };
18589
18844
  }
18590
18845
  /**
@@ -18870,8 +19125,10 @@ export class AgentFramework {
18870
19125
  * the array is text-only (let JSON path handle it; saves a code path).
18871
19126
  * `maxChars`, when provided, caps each accompanying text block so an image
18872
19127
  * 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).
19128
+ * block is cut at the cap as the stored copy's cut at that cap is
19129
+ * (toolResultCutAt), never inside a slot, so the stored copy can keep
19130
+ * exactly the slots this showed (#403); under a readers' bound the stored
19131
+ * copy's cut can come earlier (cutStoredToolResultToFit).
18875
19132
  */
18876
19133
  tryNativeToolResultContent(data, maxChars,
18877
19134
  /** Spill file written by the history path — referenced from truncation
@@ -18949,6 +19206,10 @@ export class AgentFramework {
18949
19206
  * (Sol's #94 ruling: a durable preference must not be a durable path for
18950
19207
  * one tool result to exceed maxMessageTokens; the way to see the whole
18951
19208
  * result is the spill file, not an over-bound blob in live context).
19209
+ * An ordinary resident's results land in the shared slot, which each of
19210
+ * its readers compiles under its own strategy, so the bound is the
19211
+ * smallest of theirs, as the tune-out dump's is (residentMessageChars); a
19212
+ * fork's or an ephemeral agent's store has itself as its only reader.
18952
19213
  */
18953
19214
  resolveToolResultInlineCap(agent) {
18954
19215
  const override = this.toolResultInlineMaxCharsOverride.get(agent.name);
@@ -18957,12 +19218,25 @@ export class AgentFramework {
18957
19218
  const source = override !== undefined
18958
19219
  ? 'agent-settings-override'
18959
19220
  : configured !== null ? 'framework-config' : 'default';
18960
- const strategyBound = this.strategyDerivedToolResultChars(agent);
18961
- const clamped = strategyBound !== undefined && strategyBound < desired;
19221
+ let bound = this.strategyDerivedToolResultChars(agent);
19222
+ let byReader = false;
19223
+ if (this.isOrdinaryResident(agent)) {
19224
+ for (const reader of this.sharedSlotAgents) {
19225
+ if (reader === agent)
19226
+ continue;
19227
+ const readerBound = this.strategyDerivedToolResultChars(reader);
19228
+ if (readerBound !== undefined && (bound === undefined || readerBound < bound)) {
19229
+ bound = readerBound;
19230
+ byReader = true;
19231
+ }
19232
+ }
19233
+ }
19234
+ const clamped = bound !== undefined && bound < desired;
18962
19235
  return {
18963
- cap: clamped ? strategyBound : desired,
19236
+ cap: clamped ? bound : desired,
18964
19237
  source,
18965
- clampedBy: clamped ? 'strategy-bound' : null,
19238
+ clampedBy: clamped ? (byReader ? 'reader-bound' : 'strategy-bound') : null,
19239
+ bound,
18966
19240
  };
18967
19241
  }
18968
19242
  /**
@@ -18970,8 +19244,9 @@ export class AgentFramework {
18970
19244
  * its caller whole. A call from a running script resolves to the script,
18971
19245
  * which takes any result that fits its memory (scriptResultMaxChars). Any
18972
19246
  * 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.
19247
+ * inline cap, unless its round's results together pass the readers' bound,
19248
+ * when each takes its share (buildStoredToolResultContent), and a call for
19249
+ * an agent the framework doesn't hold gets the house default.
18975
19250
  */
18976
19251
  resultMaxCharsFor(agentName, callId) {
18977
19252
  const ce = this.codeExecution;
@@ -19544,8 +19819,9 @@ export class AgentFramework {
19544
19819
  // messages flush at the next turn's START (before its compile — riding
19545
19820
  // their own wake), at the agent's next tool boundary (where they are
19546
19821
  // 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.
19822
+ // message a tune-out holds from the residents, and one the next compile
19823
+ // won't send among its newest), or in driveStream's finally when the
19824
+ // turn ends.
19549
19825
  // Quiesce (issue #122): the window exists to run compression/refold
19550
19826
  // against a FROZEN context — module events and api message.send must not
19551
19827
  // append mid-surgery. Deferred here, flushed by resume(). tool_result
@@ -19614,6 +19890,13 @@ export class AgentFramework {
19614
19890
  }
19615
19891
  return ''; // Deferred — flushed at the target's next boundary
19616
19892
  }
19893
+ // A clock row due goes first (storeDueClock). A write that gets here is a
19894
+ // tool result (which takes none), or no turn of the agent's is alive, or
19895
+ // its turn is assembling before its compile, or it comes from stop()'s
19896
+ // drain, once every stream it could join has settled: the clock is in
19897
+ // whatever request comes next.
19898
+ if (takesClockBefore(participant, content, metadata, agent.name))
19899
+ this.storeDueClock(agent, 'event');
19617
19900
  const stored = agent.getContextManager().addMessage(participant, content, opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata);
19618
19901
  if (opts?.placement) {
19619
19902
  opts.placement.agent = agent.name;
@@ -19696,6 +19979,72 @@ export class AgentFramework {
19696
19979
  if (this.quiesced || this.deferredWritesPersisted)
19697
19980
  this.persistDeferredWrites();
19698
19981
  }
19982
+ /**
19983
+ * What the next compile sends for each of `ids`, messages a tool boundary
19984
+ * has just stored, as the agent's strategy renders them as the newest of
19985
+ * its window (ContextManager.renderAsNewest): cut by the per-message cap,
19986
+ * a sharded body joined, and the image limits applied over the batch, so
19987
+ * the live stream hears what the next compile sends. An id comes back as
19988
+ * null where that compile won't send the message among its newest.
19989
+ * Undefined where the strategy has no such hook, or the render threw,
19990
+ * which is logged: the boundary then injects what it stored, as it did
19991
+ * before strategies could say.
19992
+ */
19993
+ renderForInjection(agent, ids) {
19994
+ if (ids.length === 0)
19995
+ return undefined;
19996
+ try {
19997
+ return agent.getContextManager().renderAsNewest(ids);
19998
+ }
19999
+ catch (error) {
20000
+ 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);
20001
+ return undefined;
20002
+ }
20003
+ }
20004
+ /**
20005
+ * Tell the agent of the messages a tool boundary stored and left out of
20006
+ * its injection, since the next compile won't send them among its newest:
20007
+ * who sent each and its size, and how to read it, `history--extract`
20008
+ * around it where the agent is shown that tool, else its id. None of a
20009
+ * message's words: the line stands in for a message the live round
20010
+ * doesn't carry. A system line, so it's no conversation: it neither
20011
+ * re-pins the turn nor clears its send suppression. It names the agent
20012
+ * it's for, since every resident sharing the slot reads it too. Returns
20013
+ * the notice as stored, for the boundary to inject after the batch, or
20014
+ * undefined when it couldn't be stored, which is logged.
20015
+ */
20016
+ recordLeftOutNotice(agent, leftOut) {
20017
+ const canExtract = this.advertisedToolsForAgent(agent.name).some((t) => t.name === 'history--extract');
20018
+ const one = leftOut.length === 1;
20019
+ const lines = leftOut.map(({ msg, id, content, parts }) => {
20020
+ const size = contentSize(content);
20021
+ // A sharded body is that many store messages, consecutive from its
20022
+ // first, which extract reads around it one by one. (Its "after" stops
20023
+ // at 100, so a body of more parts, over 300,000 tokens at the memory
20024
+ // strategy's default shard size, is read only that far.)
20025
+ const read = canExtract
20026
+ ? `history--extract with aroundId "${id}", before 0 and after ${parts - 1} reads it` +
20027
+ `${parts > 1 ? `, stored as ${parts} parts` : ''}.`
20028
+ : `Its id is ${id}${parts > 1 ? `, the first of the ${parts} parts it's stored as` : ''}.`;
20029
+ return `- From ${msg.participant}${size ? `, ${size}` : ''}. ${read}`;
20030
+ });
20031
+ const content = [{
20032
+ type: 'text',
20033
+ text: `[not shown] For ${agent.name}: ${one ? 'A message' : `${leftOut.length} messages`} arrived during your turn that ` +
20034
+ `${one ? "isn't" : "aren't"} shown here, since your context doesn't keep ${one ? 'it' : 'them'} among ` +
20035
+ `your newest messages:\n${lines.join('\n')}`,
20036
+ }];
20037
+ const metadata = { system: true, kind: 'left-out-notice' };
20038
+ try {
20039
+ const id = agent.getContextManager().addMessage('user', content, metadata);
20040
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'left-out-notice' });
20041
+ return { participant: 'user', content, metadata };
20042
+ }
20043
+ catch (err) {
20044
+ console.error(`[injection] ${agent.name}: couldn't store the notice of messages left out of the live round:`, err);
20045
+ return undefined;
20046
+ }
20047
+ }
19699
20048
  /**
19700
20049
  * Write drained entries in queue (seq) order, whatever their targets:
19701
20050
  * residents share one message slot, so the order entries arrived in is the
@@ -19814,7 +20163,7 @@ export class AgentFramework {
19814
20163
  continue;
19815
20164
  }
19816
20165
  if (found.length > 0) {
19817
- written.set(msg, { id: found[0].id, content: asCompiled(found) });
20166
+ written.set(msg, { id: found[0].id, content: asCompiled(found), parts: found.length });
19818
20167
  this.logDeferredWriteError(msg, where, undefined, { count: found.length, sharded: found[0].bodyGroupId !== undefined, foundLater: true });
19819
20168
  onLanded?.(msg, found[0].id);
19820
20169
  continue;
@@ -19831,7 +20180,7 @@ export class AgentFramework {
19831
20180
  const copies = agent && before !== undefined && (this.storedCountOf(agent) ?? before) - before > 1
19832
20181
  ? this.storedCopiesOf(agent, before, msg.id) ?? []
19833
20182
  : [];
19834
- written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content });
20183
+ written.set(msg, { id, content: copies.length > 1 ? asCompiled(copies) : msg.content, parts: Math.max(1, copies.length) });
19835
20184
  }
19836
20185
  catch (error) {
19837
20186
  // What landed, read back from where the store stood before the
@@ -19839,7 +20188,7 @@ export class AgentFramework {
19839
20188
  // after it. With no agent there's no store it could have landed in.
19840
20189
  const landed = !agent ? [] : before === undefined ? undefined : this.storedCopiesOf(agent, before, msg.id);
19841
20190
  if (landed && landed.length > 0) {
19842
- written.set(msg, { id: landed[0].id, content: asCompiled(landed) });
20191
+ written.set(msg, { id: landed[0].id, content: asCompiled(landed), parts: landed.length });
19843
20192
  this.logDeferredWriteError(msg, where, error, { count: landed.length, sharded: landed[0].bodyGroupId !== undefined });
19844
20193
  onLanded?.(msg, landed[0].id);
19845
20194
  continue;
@@ -19880,14 +20229,19 @@ export class AgentFramework {
19880
20229
  * `bodyGroupId`. Undefined when the store can't be read back, which is
19881
20230
  * logged: not the same as nothing found. */
19882
20231
  storedCopiesOf(agent, from, deferredWriteId) {
20232
+ return this.storedSince(agent, from, (m) => m.metadata?.deferredWriteId === deferredWriteId, 'deferral', `deferred write ${deferredWriteId}`);
20233
+ }
20234
+ /** The messages `match` picks among those the agent's store gained from
20235
+ * position `from` on, in store order, with their blobs resolved. Undefined
20236
+ * when the store can't be read back, which is logged under `tag`, naming
20237
+ * `what` was written: not the same as nothing found. */
20238
+ storedSince(agent, from, match, tag, what) {
19883
20239
  try {
19884
20240
  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);
20241
+ return cm.getMessageWindow(from, Math.max(0, cm.getMessageCount() - from)).messages.filter(match).map(storedCopy);
19888
20242
  }
19889
20243
  catch (error) {
19890
- console.error(`[deferral] could not read back what deferred write ${deferredWriteId} stored in ${agent.name}'s store:`, error);
20244
+ console.error(`[${tag}] could not read back what ${what} stored in ${agent.name}'s store:`, error);
19891
20245
  return undefined;
19892
20246
  }
19893
20247
  }
@@ -22799,6 +23153,9 @@ export class AgentFramework {
22799
23153
  // configured servers can still deploy its first one at runtime.
22800
23154
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
22801
23155
  await this.initializeMcpl([], this.mcplInferenceRoutingConfig ?? undefined);
23156
+ // The lifecycle log's replay restored any tune-out on record, and no
23157
+ // coordinator is made here to carry one.
23158
+ this.endTuneOutsWithoutCoordinator();
22802
23159
  }
22803
23160
  const collision = this.mcplPrefixCollision(config, undefined, claim);
22804
23161
  if (collision)
@@ -24468,6 +24825,47 @@ export class AgentFramework {
24468
24825
  ...structured,
24469
24826
  };
24470
24827
  }
24828
+ /**
24829
+ * A tune-out on record where this host has no tune-out coordinator: a
24830
+ * start with no subconscious makes none, and neither does a channel
24831
+ * subsystem brought up after start by connectMcplServer. Nothing then
24832
+ * diverts the channel's traffic or ever cancels its tune-out, tune_out
24833
+ * isn't offered, and channel_open, channel_close and channel_decline
24834
+ * refuse a tuned-out channel, so the channel would stay held by a decision
24835
+ * nothing here can change. Each one ends here instead, its channel decided
24836
+ * open again as a cancel leaves it, under the tune-out's own source, so an
24837
+ * agent's tune-out keeps the agent's provenance (the housekeeping guard
24838
+ * reads it). A log line names the channel and says how many of the
24839
+ * messages it diverted are held here, stored or still queued to be
24840
+ * written, which nothing will now deliver as its backlog.
24841
+ */
24842
+ endTuneOutsWithoutCoordinator() {
24843
+ const registry = this.channelRegistry;
24844
+ if (!registry || this.tuneOutCoordinator)
24845
+ return;
24846
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
24847
+ const ofEpoch = (epochId) => (m) => m.metadata?.tuneOut?.epochId === epochId;
24848
+ for (const { serverId, channelId, params, source } of registry.tuneOutsOnRecord()) {
24849
+ const diverted = typeof params.epochId !== 'string' ? 0
24850
+ : (primary?.getContextManager().getAllMessages() ?? []).filter(ofEpoch(params.epochId)).length +
24851
+ this.deferredMessages.filter((m) => (m.forAgent ?? this.primaryAgentName) === this.primaryAgentName)
24852
+ .filter(ofEpoch(params.epochId)).length;
24853
+ const held = diverted === 0 ? ''
24854
+ : diverted === 1 ? ' The 1 message it diverted stays here, and nothing will deliver it as its backlog.'
24855
+ : ` The ${diverted} messages it diverted stay here, and nothing will deliver them as its backlog.`;
24856
+ try {
24857
+ registry.cancelTuneOut(serverId, channelId, 'open', source);
24858
+ }
24859
+ catch (err) {
24860
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server is tuned out, and nothing on this host watches ` +
24861
+ `or cancels tune-outs, but ending this one failed, so it stays recorded until a start that can end it: ` +
24862
+ `${err instanceof Error ? err.message : String(err)}.${held}`);
24863
+ continue;
24864
+ }
24865
+ console.error(`[tune-out] "${channelId}" on the "${serverId}" server was tuned out, and nothing on this host watches ` +
24866
+ `or cancels tune-outs, so the tune-out ends here and the channel is decided open again.${held}`);
24867
+ }
24868
+ }
24471
24869
  /**
24472
24870
  * Build BeforeInferenceParams from agent state and trigger context.
24473
24871
  */
@@ -25262,11 +25660,24 @@ export class AgentFramework {
25262
25660
  // carries the same ref.
25263
25661
  const state = agent.state;
25264
25662
  if (state.status === 'waiting_for_tools') {
25265
- const cap = this.resolveToolResultInlineCap(agent).cap;
25663
+ // The commit stores a round as each result would be alone, or by
25664
+ // its share where they pass the bound together, which a sibling
25665
+ // still running can decide. Where the two ways differ for a result
25666
+ // counted here, the round is marked to be stored by its share
25667
+ // (toolRoundsByShare), so the count holds either way: a result
25668
+ // counted before the mark is the same both ways. The share is of
25669
+ // the round's calls, finished or not.
25670
+ const { cap, bound } = this.resolveToolResultInlineCap(agent);
25671
+ const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
25266
25672
  for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
25267
25673
  const tc = state.completed[i];
25268
25674
  const { text, shown } = this.storedToolResultText(agentName, tc, cap);
25269
- considerToolResultImages(tc.id, tc.name, storedImageSlots(text, cap, shown), 0);
25675
+ const alone = storedImageSlots(text, cap, shown, bound);
25676
+ const byShare = storedImageSlots(text, share.cap, shown, share.bound);
25677
+ if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j].text)) {
25678
+ this.toolRoundsByShare.add(state.completed);
25679
+ }
25680
+ considerToolResultImages(tc.id, tc.name, this.toolRoundsByShare.has(state.completed) ? byShare : alone, 0);
25270
25681
  }
25271
25682
  }
25272
25683
  // Walk the message store tail-first in bounded windows, re-inlining