@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.
- package/dist/src/agent.d.ts +5 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +27 -8
- package/dist/src/agent.js.map +1 -1
- package/dist/src/clock-rows.d.ts +115 -0
- package/dist/src/clock-rows.d.ts.map +1 -0
- package/dist/src/clock-rows.js +222 -0
- package/dist/src/clock-rows.js.map +1 -0
- package/dist/src/framework.d.ts +98 -6
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +514 -80
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +9 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +49 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/protocol-family.js +1 -1
- package/dist/src/mcpl/references.d.ts +4 -1
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +5 -2
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +9 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +118 -28
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +6 -2
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/text-as-stored.d.ts +31 -0
- package/dist/src/text-as-stored.d.ts.map +1 -0
- package/dist/src/text-as-stored.js +70 -0
- package/dist/src/text-as-stored.js.map +1 -0
- package/dist/src/timezone.d.ts +4 -2
- package/dist/src/timezone.d.ts.map +1 -1
- package/dist/src/timezone.js +4 -2
- package/dist/src/timezone.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +46 -7
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +60 -7
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +10 -8
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +12 -10
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +17 -6
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +35 -10
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.js +3 -3
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +13 -3
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +7 -4
- package/dist/src/types/framework.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agent.ts +27 -8
- package/src/clock-rows.ts +302 -0
- package/src/framework.ts +538 -85
- package/src/index.ts +2 -0
- package/src/mcpl/channel-registry.ts +54 -8
- package/src/mcpl/protocol-family.ts +1 -1
- package/src/mcpl/references.ts +5 -2
- package/src/modules/workspace/index.ts +121 -29
- package/src/modules/workspace/types.ts +6 -2
- package/src/text-as-stored.ts +73 -0
- package/src/timezone.ts +4 -2
- package/src/tool-image-ledger.ts +62 -8
- package/src/tool-presentation.ts +8 -1
- package/src/tool-result-guard.ts +12 -10
- package/src/tool-result-history.ts +35 -9
- package/src/tune-out/coordinator.ts +3 -3
- package/src/types/agent.ts +28 -0
- package/src/types/events.ts +13 -3
- 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
|
-
`
|
|
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).
|
|
4326
|
-
*
|
|
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
|
|
4602
|
-
//
|
|
4603
|
-
//
|
|
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
|
-
|
|
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 ${
|
|
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)) {
|
package/src/mcpl/references.ts
CHANGED
|
@@ -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.'
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
4585
|
-
|
|
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
|
-
//
|
|
4665
|
-
//
|
|
4666
|
-
|
|
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 =
|
|
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
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
*
|
|
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
|
|
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 {
|
package/src/tool-image-ledger.ts
CHANGED
|
@@ -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
|
|
330
|
-
* copy cut alike.
|
|
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
|
|
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 (
|
|
413
|
-
* result before its stored copy is
|
|
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 =
|
|
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
|
|