@animalabs/agent-framework 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/src/agent.d.ts +5 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +27 -8
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/clock-rows.d.ts +115 -0
  6. package/dist/src/clock-rows.d.ts.map +1 -0
  7. package/dist/src/clock-rows.js +222 -0
  8. package/dist/src/clock-rows.js.map +1 -0
  9. package/dist/src/framework.d.ts +98 -6
  10. package/dist/src/framework.d.ts.map +1 -1
  11. package/dist/src/framework.js +514 -80
  12. package/dist/src/framework.js.map +1 -1
  13. package/dist/src/index.d.ts +2 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/channel-registry.d.ts +9 -0
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +49 -6
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/protocol-family.js +1 -1
  22. package/dist/src/mcpl/references.d.ts +4 -1
  23. package/dist/src/mcpl/references.d.ts.map +1 -1
  24. package/dist/src/mcpl/references.js +5 -2
  25. package/dist/src/mcpl/references.js.map +1 -1
  26. package/dist/src/modules/workspace/index.d.ts +9 -1
  27. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  28. package/dist/src/modules/workspace/index.js +118 -28
  29. package/dist/src/modules/workspace/index.js.map +1 -1
  30. package/dist/src/modules/workspace/types.d.ts +6 -2
  31. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  32. package/dist/src/modules/workspace/types.js.map +1 -1
  33. package/dist/src/text-as-stored.d.ts +31 -0
  34. package/dist/src/text-as-stored.d.ts.map +1 -0
  35. package/dist/src/text-as-stored.js +70 -0
  36. package/dist/src/text-as-stored.js.map +1 -0
  37. package/dist/src/timezone.d.ts +4 -2
  38. package/dist/src/timezone.d.ts.map +1 -1
  39. package/dist/src/timezone.js +4 -2
  40. package/dist/src/timezone.js.map +1 -1
  41. package/dist/src/tool-image-ledger.d.ts +46 -7
  42. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  43. package/dist/src/tool-image-ledger.js +60 -7
  44. package/dist/src/tool-image-ledger.js.map +1 -1
  45. package/dist/src/tool-presentation.d.ts.map +1 -1
  46. package/dist/src/tool-presentation.js +9 -1
  47. package/dist/src/tool-presentation.js.map +1 -1
  48. package/dist/src/tool-result-guard.d.ts +10 -8
  49. package/dist/src/tool-result-guard.d.ts.map +1 -1
  50. package/dist/src/tool-result-guard.js +12 -10
  51. package/dist/src/tool-result-guard.js.map +1 -1
  52. package/dist/src/tool-result-history.d.ts +17 -6
  53. package/dist/src/tool-result-history.d.ts.map +1 -1
  54. package/dist/src/tool-result-history.js +35 -10
  55. package/dist/src/tool-result-history.js.map +1 -1
  56. package/dist/src/tune-out/coordinator.js +3 -3
  57. package/dist/src/tune-out/coordinator.js.map +1 -1
  58. package/dist/src/types/agent.d.ts +27 -0
  59. package/dist/src/types/agent.d.ts.map +1 -1
  60. package/dist/src/types/events.d.ts +13 -3
  61. package/dist/src/types/events.d.ts.map +1 -1
  62. package/dist/src/types/framework.d.ts +7 -4
  63. package/dist/src/types/framework.d.ts.map +1 -1
  64. package/package.json +3 -3
  65. package/src/agent.ts +27 -8
  66. package/src/clock-rows.ts +302 -0
  67. package/src/framework.ts +538 -85
  68. package/src/index.ts +2 -0
  69. package/src/mcpl/channel-registry.ts +54 -8
  70. package/src/mcpl/protocol-family.ts +1 -1
  71. package/src/mcpl/references.ts +5 -2
  72. package/src/modules/workspace/index.ts +121 -29
  73. package/src/modules/workspace/types.ts +6 -2
  74. package/src/text-as-stored.ts +73 -0
  75. package/src/timezone.ts +4 -2
  76. package/src/tool-image-ledger.ts +62 -8
  77. package/src/tool-presentation.ts +8 -1
  78. package/src/tool-result-guard.ts +12 -10
  79. package/src/tool-result-history.ts +35 -9
  80. package/src/tune-out/coordinator.ts +3 -3
  81. package/src/types/agent.ts +28 -0
  82. package/src/types/events.ts +13 -3
  83. package/src/types/framework.ts +7 -4
package/src/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)) {
@@ -434,7 +434,10 @@ export function unfetchableReason(record: ReferenceRecord, nowMs = Date.now()):
434
434
  /**
435
435
  * RFC-005 treatment for one MCPL wire block, for the model-visible text
436
436
  * lanes. Returns a replacement string, or null when the block should keep
437
- * its existing (inline/text) handling.
437
+ * its existing (inline/text) handling. A reference's stub carries text taken
438
+ * from the block's own fields, so it's made well formed (a lone UTF-16
439
+ * surrogate becomes U+FFFD), as the context store would make it: a tool
440
+ * result's copies then carry the same text (#695).
438
441
  */
439
442
  export function referenceStubOrNull(block: unknown, provenance?: string, serverId?: string): string | null {
440
443
  const c = classifyBlock(block);
@@ -442,7 +445,7 @@ export function referenceStubOrNull(block: unknown, provenance?: string, serverI
442
445
  case 'text': return null;
443
446
  case 'inline':
444
447
  return c.contradiction ? INLINE_WITHHELD_TEXT : null;
445
- case 'reference': return buildReferenceStub(c.testimony, provenance, serverId);
448
+ case 'reference': return buildReferenceStub(c.testimony, provenance, serverId).toWellFormed();
446
449
  case 'invalid': return c.shown;
447
450
  }
448
451
  }
@@ -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
@@ -1841,11 +1851,12 @@ export class WorkspaceModule implements Module {
1841
1851
  },
1842
1852
  {
1843
1853
  name: 'glob',
1844
- description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect,
1854
+ description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes. '
1855
+ + 'The pattern is relative to path (or to each mount\'s root). In the mount its first segment names, it is read mount-prefixed instead, like the paths it returns, within path if one is given; in every other mount, as written.' + globDirect,
1845
1856
  inputSchema: {
1846
1857
  type: 'object' as const,
1847
1858
  properties: {
1848
- pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.ts")' },
1859
+ pattern: { type: 'string', description: 'Glob pattern, relative (e.g., "**/*.ts") or mount-prefixed (e.g., "project/src/**/*.ts")' },
1849
1860
  path: { type: 'string', description: 'Directory to search in (mount-prefixed, optional)' },
1850
1861
  },
1851
1862
  required: ['pattern'],
@@ -1863,7 +1874,7 @@ export class WorkspaceModule implements Module {
1863
1874
  properties: {
1864
1875
  pattern: { type: 'string', description: 'Regular expression pattern' },
1865
1876
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1866
- glob: { type: 'string', description: 'Glob pattern to filter files' },
1877
+ glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' },
1867
1878
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1868
1879
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1869
1880
  },
@@ -2284,9 +2295,9 @@ export class WorkspaceModule implements Module {
2284
2295
  if (mount.direct) return this.bytesFromDisk(mount, relativePath, mountPrefixedPath, mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
2285
2296
  const store = this.getStore();
2286
2297
  return this.withMount(mount, async () => {
2287
- await this.ensureSynced(mount, relativePath);
2298
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
2288
2299
  const entry = store.treeGet(mount.treeStateId, relativePath);
2289
- if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
2300
+ if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2290
2301
  const blob = store.getBlob(entry.blobHash);
2291
2302
  if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
2292
2303
  return { data: blob };
@@ -3676,13 +3687,24 @@ export class WorkspaceModule implements Module {
3676
3687
  * path, as a file tool names the one it acts on: a path the mount passes
3677
3688
  * over by design is observed, as a sync naming it would observe it (#276),
3678
3689
  * 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.
3690
+ * passed over by any pass. Answers the pass's report on the path, and why
3691
+ * the pass decided nothing where it did (undecidedOf), which leaves disk
3692
+ * unchecked. The caller holds the mount's turn.
3680
3693
  */
3681
- private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
3694
+ private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
3682
3695
  const store = this.getStore();
3683
- if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
3696
+ if (store.treeGet(mount.treeStateId, relativePath)) return { report: undefined, undecided: null };
3684
3697
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3685
- return pass.reports.get(relativePath);
3698
+ return { report: pass.reports.get(relativePath), undecided: undecidedOf(pass) };
3699
+ }
3700
+
3701
+ /**
3702
+ * Why the workspace holds no entry at a path, after a pass over it that
3703
+ * decided nothing: disk wasn't checked, so it may have a copy, and the same
3704
+ * call made again checks it.
3705
+ */
3706
+ private notCheckedYet(path: string, reason: string): string {
3707
+ return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3686
3708
  }
3687
3709
 
3688
3710
  /** Why the workspace holds no entry at a path, when disk explains it. */
@@ -3715,7 +3737,7 @@ export class WorkspaceModule implements Module {
3715
3737
  if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
3716
3738
  const store = this.getStore();
3717
3739
  return this.withMount(mount, async () => {
3718
- const report = await this.ensureSynced(mount, relativePath);
3740
+ const { report } = await this.ensureSynced(mount, relativePath);
3719
3741
  const entry = store.treeGet(mount.treeStateId, relativePath);
3720
3742
  if (!entry) return { error: this.notInWorkspace(path, report) };
3721
3743
  const blob = store.getBlob(entry.blobHash);
@@ -4086,6 +4108,8 @@ export class WorkspaceModule implements Module {
4086
4108
  // As every file tool's pass does, it names the paths it acts on, so a
4087
4109
  // file the mount passes over by design is observed (see PassOptions.named).
4088
4110
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4111
+ // A pass that decided nothing checked no file's disk copy.
4112
+ const undecided = undecidedOf(pass);
4089
4113
  // Nothing below awaits until the commit's push.
4090
4114
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4091
4115
  const hints = hintBudget();
@@ -4109,6 +4133,20 @@ export class WorkspaceModule implements Module {
4109
4133
  // workspace didn't hold has no older copy, so taking it in needs no
4110
4134
  // word.
4111
4135
  const subject = tool === 'edit' ? 'the edit' : editList(target.edits);
4136
+ // A pass that decided nothing checked no copy on disk, held or not,
4137
+ // and took nothing in, so the edits would apply to text disk may
4138
+ // have moved past, on whichever branch is selected once it gave up.
4139
+ // They're refused, as a call made again can check.
4140
+ if (undecided !== null) {
4141
+ problems.push({
4142
+ kind: 'file',
4143
+ edits: target.edits,
4144
+ path: target.path,
4145
+ text: `${target.path} couldn't be checked against disk (${undecided}), so ${subject} changed nothing. Make the same call again, which checks disk first.`,
4146
+ whole: true,
4147
+ });
4148
+ continue;
4149
+ }
4112
4150
  const whichCopy = (verb: string): string | null => took('modified')
4113
4151
  ? `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
4152
  : report?.state === 'unverified'
@@ -4378,11 +4416,12 @@ export class WorkspaceModule implements Module {
4378
4416
  let committed = false;
4379
4417
  try {
4380
4418
  return await this.withMount(mount, async (): Promise<ToolResult> => {
4381
- const report = await this.ensureSynced(mount, relativePath);
4419
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
4382
4420
  await this.settleBeforeMutation(mount, relativePath);
4383
4421
  const entry = store.treeGet(mount.treeStateId, relativePath);
4384
4422
  if (!entry) {
4385
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
4423
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4424
+ return { success: false, error, isError: true };
4386
4425
  }
4387
4426
 
4388
4427
  const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
@@ -4578,11 +4617,23 @@ export class WorkspaceModule implements Module {
4578
4617
 
4579
4618
  // Every match lies beneath the pattern's literal directory, so the walk
4580
4619
  // starts there: past the walk's cap, a walk of the whole scope can stop
4581
- // before it gets there.
4620
+ // before it gets there. In the mount a pattern's first segment names, the
4621
+ // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4622
+ // within `path`.
4582
4623
  const patternDir = literalDirOf(input.pattern);
4583
4624
  for (const { mount, relativePath } of mountsToSearch) {
4584
- const start = await this.walkStart(mount, relativePath, patternDir);
4585
- const matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4625
+ const prefixed = prefixedGlob(input.pattern, mount.config.name);
4626
+ let start: string;
4627
+ let matched: (path: string) => boolean;
4628
+ if (prefixed) {
4629
+ const dir = deeperOf(relativePath, prefixed.dir);
4630
+ if (dir === null) continue;
4631
+ start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4632
+ matched = (path: string) => prefixed.regex.test(path);
4633
+ } else {
4634
+ start = await this.walkStart(mount, relativePath, patternDir);
4635
+ matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4636
+ }
4586
4637
  const listing = await this.listingOf(mount, start, true, matched);
4587
4638
  for (const { path, ...view } of listing.files) matches.push({ path: `${mount.config.name}/${path}`, ...view });
4588
4639
  for (const region of listing.incomplete) {
@@ -4613,7 +4664,8 @@ export class WorkspaceModule implements Module {
4613
4664
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4614
4665
  // The filter matches mount-relative paths, so every file it lets through
4615
4666
  // lies beneath its literal directory, and the walk starts at the deeper of
4616
- // that and `path`.
4667
+ // that and `path`. In the mount a filter's first segment names, its
4668
+ // mount-prefixed reading takes the filter's place (below).
4617
4669
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
4618
4670
  const contextBefore = input.contextBefore ?? 0;
4619
4671
  const contextAfter = input.contextAfter ?? 0;
@@ -4661,16 +4713,21 @@ export class WorkspaceModule implements Module {
4661
4713
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4662
4714
  continue;
4663
4715
  }
4664
- // A `path` and a filter whose directories don't overlap leave no file
4665
- // the filter can let through, so nothing is searched.
4666
- const deeper = deeperOf(relativePath, filterDir);
4716
+ // In the mount a filter's first segment names, the filter is read
4717
+ // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4718
+ // a filter whose directories don't overlap leave no file the filter can
4719
+ // let through, so nothing is searched.
4720
+ const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4721
+ const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4667
4722
  if (deeper === null) continue;
4668
4723
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4669
4724
  const searched = (text: GrepText): void => {
4670
4725
  const matches = search(text.text);
4671
4726
  if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
4672
4727
  };
4673
- const kept = (path: string): boolean => fileGlob === null || fileGlob.test(path);
4728
+ const kept = prefixed
4729
+ ? (path: string): boolean => prefixed.regex.test(path)
4730
+ : (path: string): boolean => fileGlob === null || fileGlob.test(path);
4674
4731
  const found = mount.direct
4675
4732
  ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4676
4733
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
@@ -5882,6 +5939,28 @@ function inputProblem(
5882
5939
  return null;
5883
5940
  }
5884
5941
 
5942
+ /**
5943
+ * A glob's reading in the mount its first segment names, in place of the
5944
+ * pattern as written there. Every path the tools take and return is
5945
+ * mount-prefixed, so `notes/diary/*.md` is the natural way to write a
5946
+ * pattern, and tested as written against a mount-relative path it could
5947
+ * never match — a glob answered "no matches" for files that were there.
5948
+ * `regex` matches the rest of the pattern against the mount-relative path,
5949
+ * and `dir` is the rest's literal directory (literalDirOf), from the mount's
5950
+ * root. A pattern that names another mount, or none, has no such reading
5951
+ * (null), and is read as written, relative to the search root. So in a mount
5952
+ * holding a directory named like itself, `work/z/*.ts` names the mount's own
5953
+ * `z`, and `work/work/z/*.ts`, as the tools show it, names the directory's.
5954
+ * The rest's brace groups are the pattern's, so a pattern globToRegex took
5955
+ * doesn't throw here.
5956
+ */
5957
+ function prefixedGlob(pattern: string, mountName: string): { regex: RegExp; dir: string } | null {
5958
+ const slash = pattern.indexOf('/');
5959
+ if (slash <= 0 || pattern.slice(0, slash) !== mountName) return null;
5960
+ const rest = pattern.slice(slash + 1);
5961
+ return { regex: globToRegex(rest), dir: literalDirOf(rest) };
5962
+ }
5963
+
5885
5964
  /**
5886
5965
  * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace
5887
5966
  * group it can't read: a "{" that never closes, a group inside another, or a
@@ -5916,9 +5995,14 @@ function globToRegex(pattern: string): RegExp {
5916
5995
  if (!group.includes(',')) {
5917
5996
  throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
5918
5997
  }
5919
- const alternatives = group.split(',');
5920
- regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
5921
- i = closeIdx + 1;
5998
+ // A `/` right after the group goes into each alternative, as `{a,b}/x`
5999
+ // and `{a/,b/}x` name the same paths, so an alternative's own `**`
6000
+ // meets it and reads as a `**/` does, as in the group's expansion:
6001
+ // `{src/**,lib}/*.ts` is `src/**/*.ts` or `lib/*.ts`.
6002
+ const slash = pattern[closeIdx + 1] === '/';
6003
+ const alternatives = group.split(',').map((alternative) => (slash ? alternative + '/' : alternative));
6004
+ regex += '(?:' + alternatives.map(globPartToRegex).join('|') + ')';
6005
+ i = closeIdx + (slash ? 2 : 1);
5922
6006
  continue;
5923
6007
  }
5924
6008
  // Accumulate non-brace characters, convert as a chunk
@@ -5964,11 +6048,19 @@ function braceRefusal(pattern: string, problem: string): string {
5964
6048
  return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
5965
6049
  }
5966
6050
 
6051
+ // What each glob token becomes. A `**/` at the start of a path segment matches
6052
+ // zero or more whole directories, so `src/**/*.ts` also matches `src/main.ts`;
6053
+ // any other `**` matches any run of characters.
6054
+ const GLOB_TOKENS: Record<string, string> = { '**/': '(?:.*/)?', '**': '.*', '*': '[^/]*', '?': '[^/]' };
6055
+
6056
+ // One part of a pattern, between brace groups or inside one. Whether a `**/`
6057
+ // at its start starts a path segment depends on what comes before it in the
6058
+ // path, which after a brace group is whichever alternative matched: so it
6059
+ // reads as GLOB_TOKENS' `**/` where the path so far is empty or ends with a
6060
+ // `/`, as in `{src/,lib/}**/`, and otherwise as any run of characters and
6061
+ // then a directory, as in `draft{s,ing}**/`.
6062
+ const PART_START_GLOBSTAR = '(?:(?<=^|/)(?:.*/)?|(?<!^|/).*/)';
5967
6063
  function globPartToRegex(part: string): string {
5968
- return part
5969
- .replace(/[.+^$()|[\]\\]/g, '\\$&')
5970
- .replace(/\*\*/g, '__DOUBLESTAR__')
5971
- .replace(/\*/g, '[^/]*')
5972
- .replace(/__DOUBLESTAR__/g, '.*')
5973
- .replace(/\?/g, '[^/]');
6064
+ return part.replace(/(?<=^|\/)\*\*\/|\*\*|[*?]|[.+^$()|[\]\\]/g, (m, offset: number) =>
6065
+ m === '**/' && offset === 0 ? PART_START_GLOBSTAR : GLOB_TOKENS[m] ?? `\\${m}`);
5974
6066
  }
@@ -259,8 +259,12 @@ export interface MountConfig {
259
259
  * `glob`, `grep`) take up to it from their scope's start every time:
260
260
  * `glob`'s is the directory its pattern's segments before the first
261
261
  * wildcard name beneath its `path`, and `grep`'s the deeper of its `path`
262
- * and its `glob` filter's directory, as far as disk lists each segment
263
- * with that spelling as a directory, not a link. Each directory's listing
262
+ * and its `glob` filter's directory. For a pattern or filter whose first
263
+ * segment names the mount, either tool starts at the deeper of its `path`
264
+ * and the directory the rest names from the mount's root, and walks
265
+ * nothing where neither holds the other. Either way, the walk descends as
266
+ * far as disk lists each segment with that spelling as a directory, not a
267
+ * link. Each directory's listing
264
268
  * is read whole, so a deletion is seen wherever the walk lists, whatever
265
269
  * it took.
266
270
  *
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A request carries only text the context store would keep. Apart from the
3
+ * agent's own configuration, such as its name and system prompt, what a
4
+ * request compiles comes from the store, which writes a lone UTF-16
5
+ * surrogate as U+FFFD, and a provider refuses a request carrying one (#695).
6
+ * Two things reach a request without the store, and each is made well formed
7
+ * where it leaves for it: context injections, as an Agent compiles them into
8
+ * a request, and the copies a tool boundary injects into the live round, as
9
+ * they go to the stream.
10
+ */
11
+
12
+ import type { ContentBlock } from '@animalabs/membrane';
13
+ import type { ContextInjection } from '@animalabs/context-manager';
14
+
15
+ /** A value with every string in it as the context store keeps it, through
16
+ * arrays and plain objects. Keys, and objects that aren't plain, pass as
17
+ * they are: content blocks are plain JSON data. Whatever is already well
18
+ * formed keeps its identity. */
19
+ function stringsAsStored<T>(value: T): T {
20
+ if (typeof value === 'string') return (value.isWellFormed() ? value : value.toWellFormed()) as T;
21
+ if (Array.isArray(value)) {
22
+ const out = value.map(stringsAsStored);
23
+ return (out.every((v, i) => v === value[i]) ? value : out) as T;
24
+ }
25
+ if (value !== null && typeof value === 'object') {
26
+ const proto = Object.getPrototypeOf(value);
27
+ if (proto !== Object.prototype && proto !== null) return value;
28
+ let changed = false;
29
+ const out: Record<string, unknown> = {};
30
+ for (const [k, v] of Object.entries(value)) {
31
+ const s = stringsAsStored(v);
32
+ if (s !== v) changed = true;
33
+ out[k] = s;
34
+ }
35
+ return (changed ? out : value) as T;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ /** Content with every string in it well formed, as the context store keeps
41
+ * a text block's text or an image's link. */
42
+ export function contentAsStored(content: ContentBlock[]): ContentBlock[] {
43
+ return stringsAsStored(content);
44
+ }
45
+
46
+ /** A sender's name as the context store keeps it, and as a tool boundary
47
+ * sends it: a lone UTF-16 surrogate becomes U+FFFD. A name that isn't a
48
+ * string, from an untyped module, passes as it did: thrown here, inside the
49
+ * boundary, it would keep the round's results from the stream, where it
50
+ * would otherwise fail at the next compile. */
51
+ export function nameAsStored(participant: string): string {
52
+ return typeof participant === 'string' ? participant.toWellFormed() : participant;
53
+ }
54
+
55
+ /** Injections with their text as the context store would keep it: each one's
56
+ * content, and its namespace, which context-manager writes into a beforeUser
57
+ * or afterUser injection's participant name by its text form. A namespace
58
+ * that isn't a string, which MCPL §10.4 doesn't allow but the hook
59
+ * orchestrator keeps when it can be read as text, is taken by that form.
60
+ * They're scoped to a conversation before this, by the namespace as given,
61
+ * since channel ids are compared as given. One that's already well formed
62
+ * keeps its identity. */
63
+ export function injectionsAsStored(injections: ContextInjection[] | undefined): ContextInjection[] | undefined {
64
+ return injections?.map((inj) => {
65
+ const ns: unknown = inj.namespace;
66
+ const named = typeof ns === 'string' || ns === undefined || ns === null ? ns : String(ns);
67
+ const namespace = typeof named === 'string' && !named.isWellFormed() ? named.toWellFormed() : ns;
68
+ const content = contentAsStored(inj.content);
69
+ return namespace === ns && content.every((b, i) => b === inj.content[i])
70
+ ? inj
71
+ : { ...inj, namespace: namespace as string, content };
72
+ });
73
+ }
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