@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.
- package/dist/src/agent.d.ts +5 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +6 -0
- 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 +95 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +485 -74
- 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/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 +44 -8
- package/dist/src/modules/workspace/index.js.map +1 -1
- 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-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 +2 -1
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +2 -1
- 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 +5 -1
- 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 +1 -1
- package/src/agent.ts +6 -0
- package/src/clock-rows.ts +302 -0
- package/src/framework.ts +508 -79
- package/src/index.ts +2 -0
- package/src/mcpl/channel-registry.ts +54 -8
- package/src/mcpl/protocol-family.ts +1 -1
- package/src/modules/workspace/index.ts +47 -9
- package/src/timezone.ts +4 -2
- package/src/tool-image-ledger.ts +62 -8
- package/src/tool-result-guard.ts +12 -10
- package/src/tool-result-history.ts +2 -1
- package/src/tune-out/coordinator.ts +3 -3
- package/src/types/agent.ts +28 -0
- package/src/types/events.ts +5 -1
- 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)) {
|
|
@@ -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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/tool-result-guard.ts
CHANGED
|
@@ -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
|
|
90
|
-
* they're cut, with a note that
|
|
91
|
-
* a later field can then be cut
|
|
92
|
-
* nothing, as for a failed result
|
|
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
|
|
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
|
-
/** `
|
|
317
|
-
*
|
|
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
|
-
/**
|
|
324
|
-
|
|
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,
|
|
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
|
|
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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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);
|
package/src/types/agent.ts
CHANGED
|
@@ -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. */
|
package/src/types/events.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/types/framework.ts
CHANGED
|
@@ -117,9 +117,10 @@ export interface FrameworkConfig {
|
|
|
117
117
|
subconscious?: import('../tune-out/tools.js').SubconsciousConfig;
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* IANA zone
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|