@animalabs/agent-framework 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/src/agent.d.ts +5 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +6 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/clock-rows.d.ts +115 -0
  6. package/dist/src/clock-rows.d.ts.map +1 -0
  7. package/dist/src/clock-rows.js +222 -0
  8. package/dist/src/clock-rows.js.map +1 -0
  9. package/dist/src/framework.d.ts +95 -5
  10. package/dist/src/framework.d.ts.map +1 -1
  11. package/dist/src/framework.js +485 -74
  12. package/dist/src/framework.js.map +1 -1
  13. package/dist/src/index.d.ts +2 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/channel-registry.d.ts +9 -0
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +49 -6
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/protocol-family.js +1 -1
  22. package/dist/src/modules/workspace/index.d.ts +9 -1
  23. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  24. package/dist/src/modules/workspace/index.js +44 -8
  25. package/dist/src/modules/workspace/index.js.map +1 -1
  26. package/dist/src/timezone.d.ts +4 -2
  27. package/dist/src/timezone.d.ts.map +1 -1
  28. package/dist/src/timezone.js +4 -2
  29. package/dist/src/timezone.js.map +1 -1
  30. package/dist/src/tool-image-ledger.d.ts +46 -7
  31. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  32. package/dist/src/tool-image-ledger.js +60 -7
  33. package/dist/src/tool-image-ledger.js.map +1 -1
  34. package/dist/src/tool-result-guard.d.ts +10 -8
  35. package/dist/src/tool-result-guard.d.ts.map +1 -1
  36. package/dist/src/tool-result-guard.js +12 -10
  37. package/dist/src/tool-result-guard.js.map +1 -1
  38. package/dist/src/tool-result-history.d.ts +2 -1
  39. package/dist/src/tool-result-history.d.ts.map +1 -1
  40. package/dist/src/tool-result-history.js +2 -1
  41. package/dist/src/tool-result-history.js.map +1 -1
  42. package/dist/src/tune-out/coordinator.js +3 -3
  43. package/dist/src/tune-out/coordinator.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +27 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/events.d.ts +5 -1
  47. package/dist/src/types/events.d.ts.map +1 -1
  48. package/dist/src/types/framework.d.ts +7 -4
  49. package/dist/src/types/framework.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/agent.ts +6 -0
  52. package/src/clock-rows.ts +302 -0
  53. package/src/framework.ts +508 -79
  54. package/src/index.ts +2 -0
  55. package/src/mcpl/channel-registry.ts +54 -8
  56. package/src/mcpl/protocol-family.ts +1 -1
  57. package/src/modules/workspace/index.ts +47 -9
  58. package/src/timezone.ts +4 -2
  59. package/src/tool-image-ledger.ts +62 -8
  60. package/src/tool-result-guard.ts +12 -10
  61. package/src/tool-result-history.ts +2 -1
  62. package/src/tune-out/coordinator.ts +3 -3
  63. package/src/types/agent.ts +28 -0
  64. package/src/types/events.ts +5 -1
  65. package/src/types/framework.ts +7 -4
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export { ProcessQueueImpl } from './queue.js';
10
10
  export { ModuleRegistry } from './module-registry.js';
11
11
  export { ShutdownTimeoutError, DEFAULT_SHUTDOWN_TIMEOUT_MS } from './shutdown.js';
12
12
  export { formatZonedDateTime, formatZonedTime, isValidTimeZone, resolveTimeZone } from './timezone.js';
13
+ export { CLOCK_ROW_KIND, resolveClockConfig } from './clock-rows.js';
14
+ export type { ClockConfig, ClockStamp, ResolvedClockConfig } from './clock-rows.js';
13
15
  export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
14
16
 
15
17
  // Built-in modules
@@ -813,6 +813,15 @@ function supersededClause(action: 'open' | 'close', now: DesiredChannelState | u
813
813
  return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
814
814
  }
815
815
 
816
+ /** What ends a tune-out, for a channel tool's answer about a tuned-out
817
+ * channel (`channel` as the answer shows it, quoted): its cancel, with the
818
+ * backlog it delivers, and, for a close, that channel_close closes the
819
+ * channel once the cancel has ended the tune-out. */
820
+ function tunedOutPointer(tool: string, channel: string): string {
821
+ return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
822
+ 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
823
+ }
824
+
816
825
  /** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
817
826
  * the tune-out, with the backlog it delivers. As an error result the agent
818
827
  * reads the pointer itself; `data` names the refusal for an in-process
@@ -823,8 +832,7 @@ function tunedOutRefusal(tool: string, channelId: string): ToolResult {
823
832
  isError: true,
824
833
  error:
825
834
  `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
826
- `Call tune_out with channelId "${channelId}" and mode "cancel" for it to resume normal attention; cancelling ` +
827
- 'delivers the diverted backlog.',
835
+ tunedOutPointer(tool, `"${channelId}"`),
828
836
  data: { refusal: 'tuned-out', channelId },
829
837
  };
830
838
  }
@@ -1336,6 +1344,12 @@ export class ChannelRegistry {
1336
1344
  return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
1337
1345
  `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
1338
1346
  }
1347
+ // channel_close refuses a tuned-out channel too, so calling it again
1348
+ // isn't the way: the tune-out's cancel is, and it delivers the backlog.
1349
+ if (action === 'close' && now === 'tuned-out') {
1350
+ return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
1351
+ `change that. ${tunedOutPointer('channel_close', channel)}`;
1352
+ }
1339
1353
  // The later decision's own work settles the transport, and its call
1340
1354
  // says what became of it, so this says only that this call's change
1341
1355
  // doesn't stand.
@@ -3123,6 +3137,19 @@ export class ChannelRegistry {
3123
3137
  return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
3124
3138
  }
3125
3139
 
3140
+ /** Every tune-out on record, whether or not its channel is registered
3141
+ * here yet: the decisions the lifecycle log's replay restored, and any
3142
+ * made since. */
3143
+ tuneOutsOnRecord(): Array<{ serverId: string; channelId: string; params: TuneOutParams; source: string }> {
3144
+ const found: Array<{ serverId: string; channelId: string; params: TuneOutParams; source: string }> = [];
3145
+ for (const [key, record] of this.desiredStates) {
3146
+ if (record.state !== 'tuned-out' || !record.tuneOut) continue;
3147
+ const at = key.indexOf('\u0000');
3148
+ found.push({ serverId: key.slice(0, at), channelId: key.slice(at + 1), params: record.tuneOut, source: record.source });
3149
+ }
3150
+ return found;
3151
+ }
3152
+
3126
3153
  /** Registered channel entries (read-only iteration for the coordinator). */
3127
3154
  listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> {
3128
3155
  return [...this.channels.values()].map((e) => ({
@@ -4322,8 +4349,9 @@ export class ChannelRegistry {
4322
4349
  source?: string;
4323
4350
  /** A machine close aimed at an explicitly-opened channel is refused
4324
4351
  * unless the caller certifies an explicit idle lease (a configured
4325
- * per-channel budget is consent to close at that budget). Module
4326
- * origin only, like `source`. */
4352
+ * per-channel budget is consent to close at that budget). A tuned-out
4353
+ * channel is refused even so, since its tune-out's cancel is what
4354
+ * ends it. Module origin only, like `source`. */
4327
4355
  overrideExplicitOpen?: boolean;
4328
4356
  },
4329
4357
  origin?: ChannelToolOrigin,
@@ -4401,6 +4429,18 @@ export class ChannelRegistry {
4401
4429
  }
4402
4430
  }
4403
4431
 
4432
+ // A tuned-out channel's decision is the tune-out, and a close recorded
4433
+ // over it would end it without its cancel, so the backlog it diverted
4434
+ // would never reach the agent. Its cancel is what ends it, as
4435
+ // channel_open's and channel_decline's refusals of a tuned-out channel
4436
+ // say, and the agent closes the channel after that. A module's close
4437
+ // that certified an idle lease, which the guard above lets through, is
4438
+ // refused too, as a refusal it stands down on rather than an error.
4439
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
4440
+ const refusal = tunedOutRefusal('channel_close', input.channelId);
4441
+ return isModuleOrigin ? { ...refusal, isError: false } : refusal;
4442
+ }
4443
+
4404
4444
  // A decision the log refused changed nothing, and the answer says so.
4405
4445
  try {
4406
4446
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
@@ -4598,14 +4638,20 @@ export class ChannelRegistry {
4598
4638
  'decline again to close it.'
4599
4639
  : ' If you need it closed now, call channel_close.';
4600
4640
  if (!stands) {
4601
- // channel_close closes it after all, whichever decision it was, while
4602
- // the channel is registered; a server that's up without the grant
4603
- // keeps its channels as it chooses, as the decline's refusal says.
4641
+ // channel_close closes it after all while the channel is registered,
4642
+ // over an open, or once a tune-out's cancel has ended the tune-out,
4643
+ // since it refuses a tuned-out channel; a server that's up without the
4644
+ // grant keeps its channels as it chooses, as the decline's refusal
4645
+ // says.
4604
4646
  const step = after === undefined
4605
4647
  ? `. It isn't registered here any more, so no channel tool can reach it until it's registered here again.${serverBack}`
4606
4648
  : reach === 'up' && !this.directsLifecycle(entry.serverId)
4607
4649
  ? `, and ${this.lifecycleRefusal(entry.serverId).message}; if you need it closed, ask whoever runs this host.`
4608
- : `. If you still want it closed, call channel_close.${serverBack}`;
4650
+ // channel_close refuses a tuned-out channel, so the cancel comes first.
4651
+ : decided === 'tuned-out'
4652
+ ? `. If you still want it closed, cancel the tune-out with tune_out mode "cancel", which delivers what it ` +
4653
+ `diverted, and then call channel_close.${serverBack}`
4654
+ : `. If you still want it closed, call channel_close.${serverBack}`;
4609
4655
  closeError = decided === 'tuned-out'
4610
4656
  ? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
4611
4657
  `the subconscious${step}`
@@ -733,7 +733,7 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
733
733
  if (binding.family === 'modern') {
734
734
  if (timeout !== undefined && !(Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMER_MS)) {
735
735
  problems.push(
736
- `MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${String(timeout)}); every modern call has a deadline`,
736
+ `MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${asText(timeout)}); every modern call has a deadline`,
737
737
  );
738
738
  }
739
739
  for (const path of mcplPolicyGrants(config)) {
@@ -268,6 +268,16 @@ export interface ReadFileFromDiskOptions {
268
268
  maxBytes?: number;
269
269
  }
270
270
 
271
+ /**
272
+ * Why a pass decided nothing, or null where it decided its paths. A pass gives
273
+ * up deciding, and reports no path, when the selected branch kept changing
274
+ * while it gathered, or when a sync's branch was no longer selected; it then
275
+ * carries no branchId, and the region it leaves incomplete says why.
276
+ */
277
+ function undecidedOf(pass: PassResult): string | null {
278
+ return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
279
+ }
280
+
271
281
  function errnoOf(err: unknown): string | undefined {
272
282
  return typeof err === 'object' && err !== null && 'code' in err && typeof (err as { code?: unknown }).code === 'string'
273
283
  ? (err as { code: string }).code
@@ -2284,9 +2294,9 @@ export class WorkspaceModule implements Module {
2284
2294
  if (mount.direct) return this.bytesFromDisk(mount, relativePath, mountPrefixedPath, mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
2285
2295
  const store = this.getStore();
2286
2296
  return this.withMount(mount, async () => {
2287
- await this.ensureSynced(mount, relativePath);
2297
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
2288
2298
  const entry = store.treeGet(mount.treeStateId, relativePath);
2289
- if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
2299
+ if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2290
2300
  const blob = store.getBlob(entry.blobHash);
2291
2301
  if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
2292
2302
  return { data: blob };
@@ -3676,13 +3686,24 @@ export class WorkspaceModule implements Module {
3676
3686
  * path, as a file tool names the one it acts on: a path the mount passes
3677
3687
  * over by design is observed, as a sync naming it would observe it (#276),
3678
3688
  * while a path in another mount's directory is refused before this, and
3679
- * passed over by any pass. The caller holds the mount's turn.
3689
+ * passed over by any pass. Answers the pass's report on the path, and why
3690
+ * the pass decided nothing where it did (undecidedOf), which leaves disk
3691
+ * unchecked. The caller holds the mount's turn.
3680
3692
  */
3681
- private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
3693
+ private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
3682
3694
  const store = this.getStore();
3683
- if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
3695
+ if (store.treeGet(mount.treeStateId, relativePath)) return { report: undefined, undecided: null };
3684
3696
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3685
- return pass.reports.get(relativePath);
3697
+ return { report: pass.reports.get(relativePath), undecided: undecidedOf(pass) };
3698
+ }
3699
+
3700
+ /**
3701
+ * Why the workspace holds no entry at a path, after a pass over it that
3702
+ * decided nothing: disk wasn't checked, so it may have a copy, and the same
3703
+ * call made again checks it.
3704
+ */
3705
+ private notCheckedYet(path: string, reason: string): string {
3706
+ return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3686
3707
  }
3687
3708
 
3688
3709
  /** Why the workspace holds no entry at a path, when disk explains it. */
@@ -3715,7 +3736,7 @@ export class WorkspaceModule implements Module {
3715
3736
  if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
3716
3737
  const store = this.getStore();
3717
3738
  return this.withMount(mount, async () => {
3718
- const report = await this.ensureSynced(mount, relativePath);
3739
+ const { report } = await this.ensureSynced(mount, relativePath);
3719
3740
  const entry = store.treeGet(mount.treeStateId, relativePath);
3720
3741
  if (!entry) return { error: this.notInWorkspace(path, report) };
3721
3742
  const blob = store.getBlob(entry.blobHash);
@@ -4086,6 +4107,8 @@ export class WorkspaceModule implements Module {
4086
4107
  // As every file tool's pass does, it names the paths it acts on, so a
4087
4108
  // file the mount passes over by design is observed (see PassOptions.named).
4088
4109
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4110
+ // A pass that decided nothing checked no file's disk copy.
4111
+ const undecided = undecidedOf(pass);
4089
4112
  // Nothing below awaits until the commit's push.
4090
4113
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4091
4114
  const hints = hintBudget();
@@ -4109,6 +4132,20 @@ export class WorkspaceModule implements Module {
4109
4132
  // workspace didn't hold has no older copy, so taking it in needs no
4110
4133
  // word.
4111
4134
  const subject = tool === 'edit' ? 'the edit' : editList(target.edits);
4135
+ // A pass that decided nothing checked no copy on disk, held or not,
4136
+ // and took nothing in, so the edits would apply to text disk may
4137
+ // have moved past, on whichever branch is selected once it gave up.
4138
+ // They're refused, as a call made again can check.
4139
+ if (undecided !== null) {
4140
+ problems.push({
4141
+ kind: 'file',
4142
+ edits: target.edits,
4143
+ path: target.path,
4144
+ text: `${target.path} couldn't be checked against disk (${undecided}), so ${subject} changed nothing. Make the same call again, which checks disk first.`,
4145
+ whole: true,
4146
+ });
4147
+ continue;
4148
+ }
4112
4149
  const whichCopy = (verb: string): string | null => took('modified')
4113
4150
  ? `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} had changed since the workspace last agreed with it, so it was taken in first, and ${subject} ${verb} that text.`
4114
4151
  : report?.state === 'unverified'
@@ -4378,11 +4415,12 @@ export class WorkspaceModule implements Module {
4378
4415
  let committed = false;
4379
4416
  try {
4380
4417
  return await this.withMount(mount, async (): Promise<ToolResult> => {
4381
- const report = await this.ensureSynced(mount, relativePath);
4418
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
4382
4419
  await this.settleBeforeMutation(mount, relativePath);
4383
4420
  const entry = store.treeGet(mount.treeStateId, relativePath);
4384
4421
  if (!entry) {
4385
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
4422
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4423
+ return { success: false, error, isError: true };
4386
4424
  }
4387
4425
 
4388
4426
  const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
package/src/timezone.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  * Agent-visible wall-clock formatting.
3
3
  *
4
4
  * Persistence and protocol timestamps should remain epoch milliseconds or UTC
5
- * ISO strings. These helpers are only for presentation boundaries where a
6
- * timestamp is rendered into an agent's context or a tool result.
5
+ * ISO strings. These helpers are for presentation boundaries where a
6
+ * timestamp is rendered into an agent's context or a tool result, and the
7
+ * zone resolveTimeZone gives also places clock rows' boundaries
8
+ * (clock-rows.ts).
7
9
  */
8
10
 
9
11
  export function isValidTimeZone(timeZone: string): boolean {
@@ -316,7 +316,8 @@ export interface StoredCut {
316
316
  head: string;
317
317
  /** The image slots past the cut that the stored copy re-appends after its
318
318
  * truncation notice, in text order: the newest MAX_KEPT_IMAGE_SLOTS of
319
- * those the live copy showed. */
319
+ * those the live copy showed, or under a readers' bound the newest of
320
+ * those that fit it, down to none (cutStoredToolResultToFit). */
320
321
  kept: string[];
321
322
  /** How many image slots past the cut the live copy showed. */
322
323
  fell: number;
@@ -326,8 +327,10 @@ export interface StoredCut {
326
327
  * Where a tool result's text longer than `cap` is cut: by its stored copy
327
328
  * (cutStoredToolResult), by history's own truncation (truncateForHistory),
328
329
  * and by its live copy, for each text block beside an image
329
- * (tryNativeToolResultContent, framework.ts), so the stored copy and the live
330
- * copy cut alike. The text's length when it isn't longer than `cap`.
330
+ * (tryNativeToolResultContent, framework.ts), so at one cap the stored copy
331
+ * and the live copy cut alike. Under a readers' bound the stored copy's cut
332
+ * can come earlier (cutStoredToolResultToFit). The text's length when it
333
+ * isn't longer than `cap`.
331
334
  *
332
335
  * The cut is made at the cap, or before it when the cap would split a
333
336
  * surrogate pair (safeSlice's rule), and that is where the moves back are
@@ -395,9 +398,13 @@ export function cutStoredToolResult(text: string, cap: number, shown: ShownRange
395
398
  /** What a stored copy re-appends after its truncation notice: the kept
396
399
  * slots, each on its own line so a reference stub stays parseable, under a
397
400
  * note saying how many fell past the cut, and how many of those were left
398
- * out and aren't counted. Empty when none fell past the cut. */
401
+ * out and aren't counted, all of them where none fit the bound
402
+ * (cutStoredToolResultToFit). Empty when none fell past the cut. */
399
403
  export function formatKeptImageSlots(cut: StoredCut): string {
400
404
  if (cut.fell === 0) return '';
405
+ if (cut.kept.length === 0) {
406
+ return `\n[${cut.fell} image slot(s) fell past the cut and are left out, and save_recent_image doesn't count them.]`;
407
+ }
401
408
  const leftOut = cut.fell - cut.kept.length;
402
409
  const note = leftOut === 0
403
410
  ? `${cut.fell} image slot(s) fell past the cut; kept here in order so save_recent_image still counts them:`
@@ -406,14 +413,61 @@ export function formatKeptImageSlots(cut: StoredCut): string {
406
413
  return `\n[${note}]\n${cut.kept.join('\n')}`;
407
414
  }
408
415
 
416
+ /** The most a stored copy's truncation notice takes (spillOrTruncate,
417
+ * framework.ts): the spill notice names its file twice, once in its paging
418
+ * advice, and fits this on a path from a mount name of up to about seventy
419
+ * characters. */
420
+ export const TOOL_RESULT_NOTICE_ROOM = 640;
421
+
422
+ /**
423
+ * A result's cap and bound when its message holds `n` results. A reader's
424
+ * strategy spends its per-message bound across the message's blocks in
425
+ * order, so each result takes an equal share of the readers' smallest
426
+ * bound, fixed before any result is cut, since its siblings may still be
427
+ * running when save_recent_image counts it, and its cap is at most that
428
+ * share. `keptCap` is where a withheld stub's kept fields are cut, leaving
429
+ * the stub's notice its room (tool-result-guard.ts). With no bound, each
430
+ * keeps the cap.
431
+ */
432
+ export function toolResultShare(cap: number, bound: number | undefined, n: number): { cap: number; bound: number | undefined; keptCap: number } {
433
+ if (bound === undefined) return { cap, bound, keptCap: cap };
434
+ const share = Math.floor(bound / Math.max(1, n));
435
+ return { cap: Math.min(cap, share), bound: share, keptCap: Math.min(cap, Math.max(1, share - TOOL_RESULT_NOTICE_ROOM)) };
436
+ }
437
+
438
+ /**
439
+ * Cut a tool result's text at `cap` as cutStoredToolResult does, unless its
440
+ * stored copy would then pass `bound`, the most a reader's strategy keeps of
441
+ * it (toolResultShare): the head, room for the notice after it, and the kept
442
+ * slots after that (formatKeptImageSlots). A strategy cuts a longer message
443
+ * from its end, and with it the notice that holds the way to the rest, so
444
+ * there the head moves back until the copy fits. The kept slots grow as the
445
+ * head moves back, so the cut is found again until head and slots fit or the
446
+ * head is empty. Slots that alone pass the room keep the newest that fit,
447
+ * the rest left out and uncounted, as past MAX_KEPT_IMAGE_SLOTS. A bound
448
+ * under the notice's room can't hold the notice. With no bound, or where the
449
+ * cut at the cap already fits, the cut is cutStoredToolResult's.
450
+ */
451
+ export function cutStoredToolResultToFit(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): StoredCut {
452
+ let cut = cutStoredToolResult(text, cap, shown);
453
+ if (bound === undefined || text.length <= cap) return cut;
454
+ const room = bound - TOOL_RESULT_NOTICE_ROOM;
455
+ while (cut.head.length > 0 && cut.head.length + formatKeptImageSlots(cut).length > room) {
456
+ cut = cutStoredToolResult(text, Math.max(0, room - formatKeptImageSlots(cut).length), shown);
457
+ }
458
+ while (cut.kept.length > 0 && formatKeptImageSlots(cut).length > room) cut = { ...cut, kept: cut.kept.slice(1) };
459
+ return cut;
460
+ }
461
+
409
462
  /**
410
463
  * The image slots a tool result's stored copy holds: its text's, or, when the
411
464
  * text is longer than `cap`, those the cut keeps, in head and after the
412
- * notice (cutStoredToolResult). The notice itself holds none. For counting a
413
- * result before its stored copy is written, as it will count after.
465
+ * notice (cutStoredToolResultToFit, under the readers' `bound`). The notice
466
+ * itself holds none. For counting a result before its stored copy is
467
+ * written, as it will count after.
414
468
  */
415
- export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null): ParsedImagePlaceholder[] {
416
- const cut = cutStoredToolResult(text, cap, shown);
469
+ export function storedImageSlots(text: string, cap: number, shown: ShownRanges | null, bound: number | undefined): ParsedImagePlaceholder[] {
470
+ const cut = cutStoredToolResultToFit(text, cap, shown, bound);
417
471
  return parseImagePlaceholders(cut.head + formatKeptImageSlots(cut));
418
472
  }
419
473
 
@@ -86,10 +86,11 @@ export function withheldResultNotice(spill: WithheldSpill, auditDurable: boolean
86
86
  /**
87
87
  * What a withheld result's stub keeps of it: the fields of its data that its
88
88
  * tool marked as saying what it acted on (ToolResult.keepWhenWithheld), in
89
- * the order marked, serialized as a stored result is. Past the inline cap
90
- * they're cut, with a note that says so and where the whole result is, since
91
- * a later field can then be cut away entirely. Undefined when it keeps
92
- * nothing, as for a failed result (agent-framework #277). A write's file
89
+ * the order marked, serialized as a stored result is. Past `maxChars`, the
90
+ * inline cap or less (storeResults' keptCap), they're cut, with a note that
91
+ * says so and where the whole result is, since a later field can then be cut
92
+ * away entirely. Undefined when it keeps nothing, as for a failed result
93
+ * (agent-framework #277). A write's file
93
94
  * list is evidence of what it did, rarely what drew a refusal, and keeping it
94
95
  * is what shows a clobbered file when the receipt is withheld.
95
96
  */
@@ -109,7 +110,7 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars:
109
110
  // Never half a surrogate pair: the stub goes to the provider as text, so
110
111
  // the cut can be one character short of the cap.
111
112
  const head = safeSlice(whole, 0, maxChars);
112
- return `${head} [cut at the inline cap: ${head.length} of these fields' ${whole.length} ` +
113
+ return `${head} [cut: ${head.length} of these fields' ${whole.length} ` +
113
114
  'characters; the whole result is where this note says]';
114
115
  }
115
116
 
@@ -313,15 +314,16 @@ export class ToolResultGuard {
313
314
  : JSON.parse(json);
314
315
  }
315
316
 
316
- /** `inlineCap` is the cap the caller stored `content` under; what a stub
317
- * keeps of a withheld result is cut at it too. */
317
+ /** `keptCap` is where what a stub keeps of a withheld result is cut: the
318
+ * cap the caller stored `content` under, less the stub's notice room where
319
+ * its message's bound sets it (toolResultShare, tool-image-ledger.ts). */
318
320
  storeResults(
319
321
  content: ContentBlock[],
320
322
  wireResults: ToolResult[],
321
323
  originals: CompletedToolCall[],
322
324
  metadata: MessageMetadata | undefined,
323
- /** The cap the results were stored under: what a stub keeps is cut there too. */
324
- inlineCap: number | undefined,
325
+ /** Where what a stub keeps is cut. */
326
+ keptCap: number | undefined,
325
327
  ): MessageId {
326
328
  if (!this.enabled) return this.cm.addMessage('user', content, metadata);
327
329
  if (this.pending) {
@@ -355,7 +357,7 @@ export class ToolResultGuard {
355
357
  const kept = new Map<number, string>();
356
358
  for (const [index, block] of content.entries()) {
357
359
  const result = block.type === 'tool_result' ? results.get(block.toolUseId) : undefined;
358
- const text = result ? keptWhenWithheld(result, inlineCap) : undefined;
360
+ const text = result ? keptWhenWithheld(result, keptCap) : undefined;
359
361
  if (text !== undefined) kept.set(index, text);
360
362
  }
361
363
  // Compression hold (CM >= 0.12): placed BEFORE onNewMessage fires, so no
@@ -101,7 +101,8 @@ export function nonErrorResultText(
101
101
  }
102
102
 
103
103
  /** Bounded copy of an arbitrary string with the standard truncation notice,
104
- * cut at the cap as a tool result's stored copy is (toolResultCutAt), so it
104
+ * cut at the cap where a tool result's stored copy is cut at that cap
105
+ * (toolResultCutAt), so it
105
106
  * never ends in part of a slot. Each of its callers' readers sees this
106
107
  * very text, never the images behind a placeholder, so nothing past the cut
107
108
  * is kept for save_recent_image's index (#403). The framework stores a tool
@@ -290,9 +290,9 @@ export class TuneOutCoordinator {
290
290
  startedAtSequence: this.hooks.currentSequence(),
291
291
  ...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
292
292
  };
293
- // A tune-out can end in the registry without cancel (a send into the
294
- // channel or a close sets its state there), leaving its timers armed:
295
- // clear them, so none of them acts on this one.
293
+ // A tune-out that ended in the registry without this coordinator's
294
+ // cancel would have left its timers armed: clear them, so none of them
295
+ // acts on this one.
296
296
  this.clearTimers(serverId, channelId);
297
297
  this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
298
298
  this.armExpiry(serverId, channelId, params);
@@ -218,6 +218,34 @@ export interface AgentConfig {
218
218
  * content-free system receipt is stored instead. Default false.
219
219
  */
220
220
  toolWrapperProseGuard?: boolean;
221
+
222
+ /**
223
+ * Clock rows (off when left out): when a boundary of this interval on the
224
+ * agent's local wall clock (counted from local midnight, in the
225
+ * framework's `timeZone`) has passed since the last clock row, the window
226
+ * takes one more — `Clock: 10:50 Europe/Berlin (UTC+02:00)`, with the date
227
+ * when the day has changed — before the next thing it takes: an incoming
228
+ * event, a turn's start, or, mid-turn, after a tool round's results, where
229
+ * it also joins the live stream. Only the latest boundary is written, and
230
+ * a window that holds no clock row yet takes one at the next of these. A
231
+ * clock reads the boundary's time where its tick would have stood, else
232
+ * the time it is written (after a tool's results, say), so it never
233
+ * reads earlier than a row before it. A clock wakes no one. See
234
+ * src/clock-rows.ts.
235
+ *
236
+ * - A stream that can't carry injected messages (membrane's XML prefill
237
+ * path) takes no mid-turn clock: the next event or turn start writes it.
238
+ * - On some models a clock after a tool's results drops the loop's
239
+ * earlier thinking from the request, as any message heard while acting
240
+ * does there: at most once an interval.
241
+ * - Residents share one window, so every resident reads the clock rows any
242
+ * of them writes, a resident with no `clock` of its own included. Each
243
+ * writes one when a boundary of its own interval has passed since the
244
+ * window's last clock, whichever resident wrote that. The subconscious
245
+ * takes none of its own: it reads the residents' through its merged
246
+ * view.
247
+ */
248
+ clock?: import('../clock-rows.js').ClockConfig;
221
249
  }
222
250
 
223
251
  /** The intentionally small set of agent settings that may change in-process. */
@@ -272,7 +272,11 @@ export interface ToolCall {
272
272
  * measured as the framework stores it: an error's text, or a success's data
273
273
  * as JSON (a content array as its text). For a model's call it is the
274
274
  * agent's inline cap, past which the framework spills the result to a
275
- * workspace file and sends a preview. For a call from a running
275
+ * workspace file and sends a preview. A result that long is stored whole
276
+ * unless its round's results together pass the readers' per-message bound,
277
+ * or a save_recent_image in the round counted it by its share: then each
278
+ * result of the round takes an equal share of that bound, and one past its
279
+ * share spills. For a call from a running
276
280
  * code_execution script it is the longest result that reaches the script
277
281
  * whole whatever its characters, from the script's memory: the script
278
282
  * takes an answer of up to a third of its memoryMaxBytes, in which a
@@ -117,9 +117,10 @@ export interface FrameworkConfig {
117
117
  subconscious?: import('../tune-out/tools.js').SubconsciousConfig;
118
118
 
119
119
  /**
120
- * IANA zone used only when rendering wall-clock times for the agent.
121
- * Stored/protocol timestamps remain epoch/UTC. Defaults to AGENT_TIMEZONE,
122
- * then the process zone.
120
+ * IANA zone of the agent's wall clock: it renders wall-clock times for the
121
+ * agent, and it sets where clock rows' boundaries fall (AgentConfig.clock,
122
+ * counted from local midnight here). Stored/protocol timestamps remain
123
+ * epoch/UTC. Defaults to AGENT_TIMEZONE, then the process zone.
123
124
  */
124
125
  timeZone?: string;
125
126
 
@@ -247,7 +248,9 @@ export interface FrameworkConfig {
247
248
  * takes precedence over this for that agent; the EFFECTIVE cap for every
248
249
  * source is min(desired, strategy bound) — desired values above the
249
250
  * strategy-derived bound (maxMessageTokens * 4) are hard-clamped to it,
250
- * with the clamp reported via agent_settings get.
251
+ * with the clamp reported via agent_settings get. For an ordinary resident
252
+ * the bound is the smallest among the agents that read the shared slot,
253
+ * reported as clamped by `reader-bound` when another reader's sets it.
251
254
  */
252
255
  toolResultInlineMaxChars?: number;
253
256