@modelprofile.com/flexharness-agent 6.0.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_ts_agent/index.d.ts +17 -0
- package/dist_ts_agent/index.js +14 -0
- package/dist_ts_agent/plugins.d.ts +16 -0
- package/dist_ts_agent/plugins.js +18 -0
- package/dist_ts_agent/smartagent.classes.agent.d.ts +2 -0
- package/dist_ts_agent/smartagent.classes.agent.js +95 -0
- package/dist_ts_agent/smartagent.classes.session.d.ts +90 -0
- package/dist_ts_agent/smartagent.classes.session.js +2139 -0
- package/dist_ts_agent/smartagent.classes.toolregistry.d.ts +12 -0
- package/dist_ts_agent/smartagent.classes.toolregistry.js +23 -0
- package/dist_ts_agent/smartagent.context.d.ts +3 -0
- package/dist_ts_agent/smartagent.context.js +247 -0
- package/dist_ts_agent/smartagent.events.d.ts +160 -0
- package/dist_ts_agent/smartagent.events.js +136 -0
- package/dist_ts_agent/smartagent.interfaces.d.ts +275 -0
- package/dist_ts_agent/smartagent.interfaces.js +24 -0
- package/dist_ts_agent/smartagent.persistence.d.ts +88 -0
- package/dist_ts_agent/smartagent.persistence.js +677 -0
- package/dist_ts_agent/smartagent.transactions.d.ts +15 -0
- package/dist_ts_agent/smartagent.transactions.js +141 -0
- package/dist_ts_agent/tool.adapter.d.ts +29 -0
- package/dist_ts_agent/tool.adapter.js +60 -0
- package/dist_ts_agent/tool.contracts.d.ts +126 -0
- package/dist_ts_agent/tool.contracts.js +2 -0
- package/dist_ts_agent/tool.persistence.d.ts +23 -0
- package/dist_ts_agent/tool.persistence.js +35 -0
- package/license.md +21 -0
- package/package.json +31 -0
- package/readme.md +37 -0
- package/ts_agent/index.ts +119 -0
- package/ts_agent/plugins.ts +62 -0
- package/ts_agent/readme.md +37 -0
- package/ts_agent/smartagent.classes.agent.ts +104 -0
- package/ts_agent/smartagent.classes.session.ts +2588 -0
- package/ts_agent/smartagent.classes.toolregistry.ts +26 -0
- package/ts_agent/smartagent.context.ts +280 -0
- package/ts_agent/smartagent.events.ts +347 -0
- package/ts_agent/smartagent.interfaces.ts +365 -0
- package/ts_agent/smartagent.persistence.ts +827 -0
- package/ts_agent/smartagent.transactions.ts +193 -0
- package/ts_agent/tool.adapter.ts +85 -0
- package/ts_agent/tool.contracts.ts +149 -0
- package/ts_agent/tool.persistence.ts +64 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { TAgentEvent } from './smartagent.events.js';
|
|
3
|
+
|
|
4
|
+
const SNAPSHOT_SCHEMA_VERSION = 2 as const;
|
|
5
|
+
const ARCHIVE_SCHEMA_VERSION = 2 as const;
|
|
6
|
+
|
|
7
|
+
interface IAgentEventSnapshotBase {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
revision: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
events: TAgentEvent[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Legacy schema-1 snapshot contract used by IAgentEventStore. */
|
|
15
|
+
export interface IAgentEventSnapshot extends IAgentEventSnapshotBase {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IAgentEventSnapshotV2 extends IAgentEventSnapshotBase {
|
|
20
|
+
schemaVersion: 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TAgentEventSnapshot = IAgentEventSnapshot | IAgentEventSnapshotV2;
|
|
24
|
+
|
|
25
|
+
interface IAgentEventArchiveBase {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
archiveId: string;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
events: TAgentEvent[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Legacy schema-1 archive contract used by IAgentEventStore. */
|
|
33
|
+
export interface IAgentEventArchive extends IAgentEventArchiveBase {
|
|
34
|
+
schemaVersion: 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IAgentEventArchiveV2 extends IAgentEventArchiveBase {
|
|
38
|
+
schemaVersion: 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type TAgentEventArchive = IAgentEventArchive | IAgentEventArchiveV2;
|
|
42
|
+
|
|
43
|
+
/** Legacy custom-store contract. It must read and write schema 1 only. */
|
|
44
|
+
export interface IAgentEventStore {
|
|
45
|
+
load(sessionId: string): Promise<IAgentEventSnapshot | undefined>;
|
|
46
|
+
save(
|
|
47
|
+
sessionId: string,
|
|
48
|
+
events: readonly TAgentEvent[],
|
|
49
|
+
expectedRevision: number,
|
|
50
|
+
): Promise<number>;
|
|
51
|
+
archive?(archive: IAgentEventArchive): Promise<void>;
|
|
52
|
+
loadArchive?(sessionId: string, archiveId: string): Promise<IAgentEventArchive | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Schema-2 capability required for persisted transactional generations. */
|
|
56
|
+
export interface IAgentEventStoreV2 {
|
|
57
|
+
readonly eventSchemaVersion: 2;
|
|
58
|
+
load(sessionId: string): Promise<TAgentEventSnapshot | undefined>;
|
|
59
|
+
save(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
events: readonly TAgentEvent[],
|
|
62
|
+
expectedRevision: number,
|
|
63
|
+
): Promise<number>;
|
|
64
|
+
archive?(archive: IAgentEventArchiveV2): Promise<void>;
|
|
65
|
+
loadArchive?(sessionId: string, archiveId: string): Promise<TAgentEventArchive | undefined>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type TAgentEventStore = IAgentEventStore | IAgentEventStoreV2;
|
|
69
|
+
|
|
70
|
+
export const isAgentEventStoreV2 = (
|
|
71
|
+
store: TAgentEventStore | undefined,
|
|
72
|
+
): store is IAgentEventStoreV2 => !!store
|
|
73
|
+
&& 'eventSchemaVersion' in store
|
|
74
|
+
&& store.eventSchemaVersion === 2;
|
|
75
|
+
|
|
76
|
+
type TUnknownRecord = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
const AGENT_EVENT_TYPES = new Set<string>([
|
|
79
|
+
'system-message',
|
|
80
|
+
'user-message',
|
|
81
|
+
'assistant-message',
|
|
82
|
+
'tool-call',
|
|
83
|
+
'tool-result',
|
|
84
|
+
'runtime-event',
|
|
85
|
+
'model-message',
|
|
86
|
+
'generation-begun',
|
|
87
|
+
'generation-execution-started',
|
|
88
|
+
'generation-execution-completed',
|
|
89
|
+
'generation-outcome',
|
|
90
|
+
'tool-execution-intent',
|
|
91
|
+
'tool-execution-reconciliation',
|
|
92
|
+
'context-compaction',
|
|
93
|
+
] satisfies TAgentEvent['type'][]);
|
|
94
|
+
|
|
95
|
+
const AGENT_CONVERSATION_EVENT_TYPES = new Set<string>([
|
|
96
|
+
'system-message',
|
|
97
|
+
'user-message',
|
|
98
|
+
'assistant-message',
|
|
99
|
+
'tool-call',
|
|
100
|
+
'tool-result',
|
|
101
|
+
'model-message',
|
|
102
|
+
] satisfies TAgentEvent['type'][]);
|
|
103
|
+
|
|
104
|
+
const hasOwn = (value: TUnknownRecord, key: string): boolean =>
|
|
105
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
106
|
+
|
|
107
|
+
const validationError = (path: string, message: string): never => {
|
|
108
|
+
throw new Error(`${path} ${message}`);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const assertRecord: (
|
|
112
|
+
value: unknown,
|
|
113
|
+
path: string,
|
|
114
|
+
) => asserts value is TUnknownRecord = (
|
|
115
|
+
value: unknown,
|
|
116
|
+
path: string,
|
|
117
|
+
): void => {
|
|
118
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
119
|
+
validationError(path, 'must be an object.');
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const assertNonEmptyString: (
|
|
124
|
+
value: unknown,
|
|
125
|
+
path: string,
|
|
126
|
+
) => asserts value is string = (
|
|
127
|
+
value: unknown,
|
|
128
|
+
path: string,
|
|
129
|
+
): void => {
|
|
130
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
131
|
+
validationError(path, 'must be a non-empty string.');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const assertOptionalString = (value: unknown, path: string): void => {
|
|
136
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
137
|
+
validationError(path, 'must be a string when provided.');
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const assertTimestamp = (value: unknown, path: string): void => {
|
|
142
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
143
|
+
validationError(path, 'must be a non-negative finite number.');
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const assertPositiveInteger: (
|
|
148
|
+
value: unknown,
|
|
149
|
+
path: string,
|
|
150
|
+
) => asserts value is number = (value: unknown, path: string): void => {
|
|
151
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
|
|
152
|
+
validationError(path, 'must be a positive safe integer.');
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const assertStringArray: (
|
|
157
|
+
value: unknown,
|
|
158
|
+
path: string,
|
|
159
|
+
) => asserts value is string[] = (value: unknown, path: string): void => {
|
|
160
|
+
if (!Array.isArray(value)) return validationError(path, 'must be an array.');
|
|
161
|
+
const values = new Set<string>();
|
|
162
|
+
for (const [index, entry] of value.entries()) {
|
|
163
|
+
assertNonEmptyString(entry, `${path}[${index}]`);
|
|
164
|
+
if (values.has(entry)) validationError(path, 'must not contain duplicate values.');
|
|
165
|
+
values.add(entry);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const assertProviderOptions = (value: unknown, path: string): void => {
|
|
170
|
+
if (value !== undefined) assertRecord(value, path);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const assertToolResultOutput = (value: unknown, path: string): void => {
|
|
174
|
+
assertRecord(value, path);
|
|
175
|
+
assertNonEmptyString(value.type, `${path}.type`);
|
|
176
|
+
assertProviderOptions(value.providerOptions, `${path}.providerOptions`);
|
|
177
|
+
if (value.type === 'text' || value.type === 'error-text') {
|
|
178
|
+
if (typeof value.value !== 'string') validationError(`${path}.value`, 'must be a string.');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (value.type === 'json' || value.type === 'error-json') {
|
|
182
|
+
if (!hasOwn(value, 'value')) validationError(`${path}.value`, 'is required.');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (value.type === 'execution-denied') {
|
|
186
|
+
assertOptionalString(value.reason, `${path}.reason`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (value.type !== 'content') return validationError(`${path}.type`, 'is invalid.');
|
|
190
|
+
if (!Array.isArray(value.value)) {
|
|
191
|
+
return validationError(`${path}.value`, 'must be an array.');
|
|
192
|
+
}
|
|
193
|
+
for (const [index, entryValue] of value.value.entries()) {
|
|
194
|
+
const entryPath = `${path}.value[${index}]`;
|
|
195
|
+
assertRecord(entryValue, entryPath);
|
|
196
|
+
assertNonEmptyString(entryValue.type, `${entryPath}.type`);
|
|
197
|
+
assertProviderOptions(entryValue.providerOptions, `${entryPath}.providerOptions`);
|
|
198
|
+
if (entryValue.type === 'text') {
|
|
199
|
+
if (typeof entryValue.text !== 'string') validationError(`${entryPath}.text`, 'must be a string.');
|
|
200
|
+
} else if (
|
|
201
|
+
entryValue.type === 'media'
|
|
202
|
+
|| entryValue.type === 'file-data'
|
|
203
|
+
|| entryValue.type === 'image-data'
|
|
204
|
+
) {
|
|
205
|
+
if (typeof entryValue.data !== 'string') validationError(`${entryPath}.data`, 'must be a string.');
|
|
206
|
+
if (typeof entryValue.mediaType !== 'string') {
|
|
207
|
+
validationError(`${entryPath}.mediaType`, 'must be a string.');
|
|
208
|
+
}
|
|
209
|
+
assertOptionalString(entryValue.filename, `${entryPath}.filename`);
|
|
210
|
+
} else if (entryValue.type === 'file-url' || entryValue.type === 'image-url') {
|
|
211
|
+
if (typeof entryValue.url !== 'string') validationError(`${entryPath}.url`, 'must be a string.');
|
|
212
|
+
} else if (entryValue.type === 'file-id' || entryValue.type === 'image-file-id') {
|
|
213
|
+
if (typeof entryValue.fileId !== 'string') {
|
|
214
|
+
assertRecord(entryValue.fileId, `${entryPath}.fileId`);
|
|
215
|
+
for (const [provider, fileId] of Object.entries(entryValue.fileId)) {
|
|
216
|
+
if (typeof fileId !== 'string') {
|
|
217
|
+
validationError(`${entryPath}.fileId.${provider}`, 'must be a string.');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else if (entryValue.type !== 'custom') {
|
|
222
|
+
validationError(`${entryPath}.type`, 'is invalid.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const assertMessageContentPart = (
|
|
228
|
+
value: unknown,
|
|
229
|
+
path: string,
|
|
230
|
+
role: 'user' | 'assistant' | 'tool',
|
|
231
|
+
): void => {
|
|
232
|
+
assertRecord(value, path);
|
|
233
|
+
assertNonEmptyString(value.type, `${path}.type`);
|
|
234
|
+
assertProviderOptions(value.providerOptions, `${path}.providerOptions`);
|
|
235
|
+
if (value.type === 'text' || value.type === 'reasoning') {
|
|
236
|
+
if (value.type === 'reasoning' && role !== 'assistant') {
|
|
237
|
+
validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
238
|
+
}
|
|
239
|
+
if (typeof value.text !== 'string') validationError(`${path}.text`, 'must be a string.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (value.type === 'image') {
|
|
243
|
+
if (role !== 'user') validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
244
|
+
if (!hasOwn(value, 'image')) validationError(`${path}.image`, 'is required.');
|
|
245
|
+
assertOptionalString(value.mediaType, `${path}.mediaType`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (value.type === 'file') {
|
|
249
|
+
if (role === 'tool') validationError(`${path}.type`, 'is invalid for a tool message.');
|
|
250
|
+
if (!hasOwn(value, 'data')) validationError(`${path}.data`, 'is required.');
|
|
251
|
+
if (typeof value.mediaType !== 'string') validationError(`${path}.mediaType`, 'must be a string.');
|
|
252
|
+
assertOptionalString(value.filename, `${path}.filename`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (value.type === 'tool-call') {
|
|
256
|
+
if (role !== 'assistant') validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
257
|
+
assertNonEmptyString(value.toolCallId, `${path}.toolCallId`);
|
|
258
|
+
assertNonEmptyString(value.toolName, `${path}.toolName`);
|
|
259
|
+
if (!hasOwn(value, 'input')) validationError(`${path}.input`, 'is required.');
|
|
260
|
+
if (value.providerExecuted !== undefined && typeof value.providerExecuted !== 'boolean') {
|
|
261
|
+
validationError(`${path}.providerExecuted`, 'must be a boolean when provided.');
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (value.type === 'tool-result') {
|
|
266
|
+
if (role === 'user') validationError(`${path}.type`, 'is invalid for a user message.');
|
|
267
|
+
assertNonEmptyString(value.toolCallId, `${path}.toolCallId`);
|
|
268
|
+
assertNonEmptyString(value.toolName, `${path}.toolName`);
|
|
269
|
+
if (!hasOwn(value, 'output')) validationError(`${path}.output`, 'is required.');
|
|
270
|
+
assertToolResultOutput(value.output, `${path}.output`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (value.type === 'tool-approval-request') {
|
|
274
|
+
if (role !== 'assistant') validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
275
|
+
assertNonEmptyString(value.approvalId, `${path}.approvalId`);
|
|
276
|
+
assertNonEmptyString(value.toolCallId, `${path}.toolCallId`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (value.type === 'tool-approval-response') {
|
|
280
|
+
if (role !== 'tool') validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
281
|
+
assertNonEmptyString(value.approvalId, `${path}.approvalId`);
|
|
282
|
+
if (typeof value.approved !== 'boolean') validationError(`${path}.approved`, 'must be a boolean.');
|
|
283
|
+
assertOptionalString(value.reason, `${path}.reason`);
|
|
284
|
+
if (value.providerExecuted !== undefined && typeof value.providerExecuted !== 'boolean') {
|
|
285
|
+
validationError(`${path}.providerExecuted`, 'must be a boolean when provided.');
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
validationError(`${path}.type`, `is invalid for a ${role} message.`);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const assertMessageContent = (
|
|
293
|
+
value: unknown,
|
|
294
|
+
path: string,
|
|
295
|
+
role: 'system' | 'user' | 'assistant' | 'tool',
|
|
296
|
+
): void => {
|
|
297
|
+
if (role === 'system') {
|
|
298
|
+
if (typeof value !== 'string') validationError(path, 'must be a string.');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (typeof value === 'string' && role !== 'tool') return;
|
|
302
|
+
if (!Array.isArray(value)) {
|
|
303
|
+
return validationError(path, 'must be a string or content-part array.');
|
|
304
|
+
}
|
|
305
|
+
for (const [index, part] of value.entries()) {
|
|
306
|
+
assertMessageContentPart(part, `${path}[${index}]`, role);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const assertModelMessage = (value: unknown, path: string): void => {
|
|
311
|
+
assertRecord(value, path);
|
|
312
|
+
const role = value.role;
|
|
313
|
+
if (
|
|
314
|
+
role !== 'system'
|
|
315
|
+
&& role !== 'user'
|
|
316
|
+
&& role !== 'assistant'
|
|
317
|
+
&& role !== 'tool'
|
|
318
|
+
) return validationError(`${path}.role`, 'is invalid.');
|
|
319
|
+
if (!hasOwn(value, 'content')) validationError(`${path}.content`, 'is required.');
|
|
320
|
+
assertMessageContent(value.content, `${path}.content`, role);
|
|
321
|
+
assertProviderOptions(value.providerOptions, `${path}.providerOptions`);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const assertRequiredGenerationId = (event: TUnknownRecord, path: string): void => {
|
|
325
|
+
assertNonEmptyString(event.generationId, `${path}.generationId`);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const assertArchivedTransactions = (value: unknown, path: string): void => {
|
|
329
|
+
if (!Array.isArray(value)) return validationError(path, 'must be an array.');
|
|
330
|
+
const generationIds = new Set<string>();
|
|
331
|
+
for (const [index, entryValue] of value.entries()) {
|
|
332
|
+
const entryPath = `${path}[${index}]`;
|
|
333
|
+
assertRecord(entryValue, entryPath);
|
|
334
|
+
assertNonEmptyString(entryValue.generationId, `${entryPath}.generationId`);
|
|
335
|
+
assertNonEmptyString(entryValue.claimTokenDigest, `${entryPath}.claimTokenDigest`);
|
|
336
|
+
if (generationIds.has(entryValue.generationId)) {
|
|
337
|
+
validationError(path, 'must not contain duplicate generationId values.');
|
|
338
|
+
}
|
|
339
|
+
generationIds.add(entryValue.generationId);
|
|
340
|
+
if (
|
|
341
|
+
entryValue.outcome !== 'accepted'
|
|
342
|
+
&& entryValue.outcome !== 'rejected'
|
|
343
|
+
&& entryValue.outcome !== 'interrupted'
|
|
344
|
+
) validationError(`${entryPath}.outcome`, 'is invalid.');
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const validateAgentEvents = (
|
|
349
|
+
value: unknown,
|
|
350
|
+
path: string,
|
|
351
|
+
conversationOnly = false,
|
|
352
|
+
): TAgentEvent[] => {
|
|
353
|
+
if (!Array.isArray(value)) return validationError(path, 'must be an array.');
|
|
354
|
+
const eventIds = new Set<string>();
|
|
355
|
+
let priorSequence = 0;
|
|
356
|
+
for (const [index, eventValue] of value.entries()) {
|
|
357
|
+
const eventPath = `${path}[${index}]`;
|
|
358
|
+
assertRecord(eventValue, eventPath);
|
|
359
|
+
assertNonEmptyString(eventValue.id, `${eventPath}.id`);
|
|
360
|
+
if (eventIds.has(eventValue.id)) validationError(path, 'must not contain duplicate event IDs.');
|
|
361
|
+
eventIds.add(eventValue.id);
|
|
362
|
+
assertTimestamp(eventValue.timestamp, `${eventPath}.timestamp`);
|
|
363
|
+
if (eventValue.sequence !== undefined) {
|
|
364
|
+
assertPositiveInteger(eventValue.sequence, `${eventPath}.sequence`);
|
|
365
|
+
if (eventValue.sequence <= priorSequence) {
|
|
366
|
+
validationError(`${eventPath}.sequence`, 'must be strictly monotonic.');
|
|
367
|
+
}
|
|
368
|
+
priorSequence = eventValue.sequence;
|
|
369
|
+
}
|
|
370
|
+
if (eventValue.parentIds !== undefined) {
|
|
371
|
+
assertStringArray(eventValue.parentIds, `${eventPath}.parentIds`);
|
|
372
|
+
}
|
|
373
|
+
for (const field of ['causationId', 'generationId', 'inferenceId'] as const) {
|
|
374
|
+
if (eventValue[field] !== undefined) {
|
|
375
|
+
assertNonEmptyString(eventValue[field], `${eventPath}.${field}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
assertNonEmptyString(eventValue.type, `${eventPath}.type`);
|
|
379
|
+
if (!AGENT_EVENT_TYPES.has(eventValue.type)) validationError(`${eventPath}.type`, 'is invalid.');
|
|
380
|
+
if (conversationOnly && !AGENT_CONVERSATION_EVENT_TYPES.has(eventValue.type)) {
|
|
381
|
+
validationError(`${eventPath}.type`, 'must be a conversation event.');
|
|
382
|
+
}
|
|
383
|
+
switch (eventValue.type) {
|
|
384
|
+
case 'system-message':
|
|
385
|
+
if (!hasOwn(eventValue, 'content')) validationError(`${eventPath}.content`, 'is required.');
|
|
386
|
+
assertMessageContent(eventValue.content, `${eventPath}.content`, 'system');
|
|
387
|
+
assertProviderOptions(eventValue.providerOptions, `${eventPath}.providerOptions`);
|
|
388
|
+
break;
|
|
389
|
+
case 'user-message':
|
|
390
|
+
case 'assistant-message':
|
|
391
|
+
if (!hasOwn(eventValue, 'content')) validationError(`${eventPath}.content`, 'is required.');
|
|
392
|
+
assertMessageContent(eventValue.content, `${eventPath}.content`, eventValue.type === 'user-message'
|
|
393
|
+
? 'user'
|
|
394
|
+
: 'assistant');
|
|
395
|
+
assertProviderOptions(eventValue.providerOptions, `${eventPath}.providerOptions`);
|
|
396
|
+
break;
|
|
397
|
+
case 'tool-call':
|
|
398
|
+
assertNonEmptyString(eventValue.toolCallId, `${eventPath}.toolCallId`);
|
|
399
|
+
assertNonEmptyString(eventValue.toolName, `${eventPath}.toolName`);
|
|
400
|
+
if (!hasOwn(eventValue, 'input')) validationError(`${eventPath}.input`, 'is required.');
|
|
401
|
+
if (eventValue.providerExecuted !== undefined && typeof eventValue.providerExecuted !== 'boolean') {
|
|
402
|
+
validationError(`${eventPath}.providerExecuted`, 'must be a boolean when provided.');
|
|
403
|
+
}
|
|
404
|
+
assertProviderOptions(eventValue.providerOptions, `${eventPath}.providerOptions`);
|
|
405
|
+
break;
|
|
406
|
+
case 'tool-result':
|
|
407
|
+
assertNonEmptyString(eventValue.toolCallId, `${eventPath}.toolCallId`);
|
|
408
|
+
assertNonEmptyString(eventValue.toolName, `${eventPath}.toolName`);
|
|
409
|
+
if (eventValue.executionIntentId !== undefined) {
|
|
410
|
+
assertNonEmptyString(eventValue.executionIntentId, `${eventPath}.executionIntentId`);
|
|
411
|
+
}
|
|
412
|
+
assertOptionalString(eventValue.error, `${eventPath}.error`);
|
|
413
|
+
if (
|
|
414
|
+
eventValue.responseMessageCommitted !== undefined
|
|
415
|
+
&& typeof eventValue.responseMessageCommitted !== 'boolean'
|
|
416
|
+
) {
|
|
417
|
+
validationError(`${eventPath}.responseMessageCommitted`, 'must be a boolean when provided.');
|
|
418
|
+
}
|
|
419
|
+
if (eventValue.modelOutput !== undefined) {
|
|
420
|
+
assertToolResultOutput(eventValue.modelOutput, `${eventPath}.modelOutput`);
|
|
421
|
+
}
|
|
422
|
+
assertProviderOptions(eventValue.providerOptions, `${eventPath}.providerOptions`);
|
|
423
|
+
assertProviderOptions(eventValue.messageProviderOptions, `${eventPath}.messageProviderOptions`);
|
|
424
|
+
break;
|
|
425
|
+
case 'runtime-event':
|
|
426
|
+
assertRecord(eventValue.event, `${eventPath}.event`);
|
|
427
|
+
assertNonEmptyString(eventValue.event.type, `${eventPath}.event.type`);
|
|
428
|
+
if (eventValue.modelVisible !== undefined && typeof eventValue.modelVisible !== 'boolean') {
|
|
429
|
+
validationError(`${eventPath}.modelVisible`, 'must be a boolean when provided.');
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
case 'model-message':
|
|
433
|
+
assertModelMessage(eventValue.message, `${eventPath}.message`);
|
|
434
|
+
break;
|
|
435
|
+
case 'generation-begun':
|
|
436
|
+
assertRequiredGenerationId(eventValue, eventPath);
|
|
437
|
+
assertNonEmptyString(eventValue.claimTokenDigest, `${eventPath}.claimTokenDigest`);
|
|
438
|
+
break;
|
|
439
|
+
case 'generation-execution-started':
|
|
440
|
+
case 'generation-execution-completed':
|
|
441
|
+
assertRequiredGenerationId(eventValue, eventPath);
|
|
442
|
+
break;
|
|
443
|
+
case 'generation-outcome':
|
|
444
|
+
assertRequiredGenerationId(eventValue, eventPath);
|
|
445
|
+
if (
|
|
446
|
+
eventValue.outcome !== 'accepted'
|
|
447
|
+
&& eventValue.outcome !== 'rejected'
|
|
448
|
+
&& eventValue.outcome !== 'interrupted'
|
|
449
|
+
) validationError(`${eventPath}.outcome`, 'is invalid.');
|
|
450
|
+
assertOptionalString(eventValue.reason, `${eventPath}.reason`);
|
|
451
|
+
break;
|
|
452
|
+
case 'tool-execution-intent':
|
|
453
|
+
assertRequiredGenerationId(eventValue, eventPath);
|
|
454
|
+
assertNonEmptyString(eventValue.toolCallId, `${eventPath}.toolCallId`);
|
|
455
|
+
assertNonEmptyString(eventValue.toolName, `${eventPath}.toolName`);
|
|
456
|
+
if (!hasOwn(eventValue, 'input')) validationError(`${eventPath}.input`, 'is required.');
|
|
457
|
+
break;
|
|
458
|
+
case 'tool-execution-reconciliation':
|
|
459
|
+
assertRequiredGenerationId(eventValue, eventPath);
|
|
460
|
+
assertNonEmptyString(eventValue.intentId, `${eventPath}.intentId`);
|
|
461
|
+
assertNonEmptyString(eventValue.toolCallId, `${eventPath}.toolCallId`);
|
|
462
|
+
assertNonEmptyString(eventValue.toolName, `${eventPath}.toolName`);
|
|
463
|
+
if (
|
|
464
|
+
eventValue.resolution !== 'executed'
|
|
465
|
+
&& eventValue.resolution !== 'not-executed'
|
|
466
|
+
&& eventValue.resolution !== 'abandoned-unknown'
|
|
467
|
+
) validationError(`${eventPath}.resolution`, 'is invalid.');
|
|
468
|
+
assertOptionalString(eventValue.error, `${eventPath}.error`);
|
|
469
|
+
if (eventValue.modelOutput !== undefined) {
|
|
470
|
+
assertToolResultOutput(eventValue.modelOutput, `${eventPath}.modelOutput`);
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
case 'context-compaction':
|
|
474
|
+
assertStringArray(eventValue.coveredEventIds, `${eventPath}.coveredEventIds`);
|
|
475
|
+
validateAgentEvents(eventValue.replacementEvents, `${eventPath}.replacementEvents`, true);
|
|
476
|
+
if (eventValue.archivedTransactions !== undefined) {
|
|
477
|
+
assertArchivedTransactions(
|
|
478
|
+
eventValue.archivedTransactions,
|
|
479
|
+
`${eventPath}.archivedTransactions`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return value as TAgentEvent[];
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** Validates and returns a canonical schema-2 Agent event snapshot. */
|
|
489
|
+
export const validateAgentEventSnapshotV2 = (
|
|
490
|
+
value: unknown,
|
|
491
|
+
expectedSessionId?: string,
|
|
492
|
+
): IAgentEventSnapshotV2 => {
|
|
493
|
+
assertRecord(value, 'Agent event snapshot');
|
|
494
|
+
if (value.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
495
|
+
validationError('Agent event snapshot schemaVersion', 'must be 2.');
|
|
496
|
+
}
|
|
497
|
+
assertNonEmptyString(value.sessionId, 'Agent event snapshot sessionId');
|
|
498
|
+
if (expectedSessionId !== undefined && value.sessionId !== expectedSessionId) {
|
|
499
|
+
validationError('Agent event snapshot sessionId', `must equal "${expectedSessionId}".`);
|
|
500
|
+
}
|
|
501
|
+
assertPositiveInteger(value.revision, 'Agent event snapshot revision');
|
|
502
|
+
assertTimestamp(value.updatedAt, 'Agent event snapshot updatedAt');
|
|
503
|
+
validateAgentEvents(value.events, 'Agent event snapshot events');
|
|
504
|
+
return value as unknown as IAgentEventSnapshotV2;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/** Validates and returns a canonical schema-2 Agent event archive. */
|
|
508
|
+
export const validateAgentEventArchiveV2 = (
|
|
509
|
+
value: unknown,
|
|
510
|
+
expectedSessionId?: string,
|
|
511
|
+
expectedArchiveId?: string,
|
|
512
|
+
): IAgentEventArchiveV2 => {
|
|
513
|
+
assertRecord(value, 'Agent event archive');
|
|
514
|
+
if (value.schemaVersion !== ARCHIVE_SCHEMA_VERSION) {
|
|
515
|
+
validationError('Agent event archive schemaVersion', 'must be 2.');
|
|
516
|
+
}
|
|
517
|
+
assertNonEmptyString(value.sessionId, 'Agent event archive sessionId');
|
|
518
|
+
if (expectedSessionId !== undefined && value.sessionId !== expectedSessionId) {
|
|
519
|
+
validationError('Agent event archive sessionId', `must equal "${expectedSessionId}".`);
|
|
520
|
+
}
|
|
521
|
+
assertNonEmptyString(value.archiveId, 'Agent event archive archiveId');
|
|
522
|
+
if (expectedArchiveId !== undefined && value.archiveId !== expectedArchiveId) {
|
|
523
|
+
validationError('Agent event archive archiveId', `must equal "${expectedArchiveId}".`);
|
|
524
|
+
}
|
|
525
|
+
assertTimestamp(value.createdAt, 'Agent event archive createdAt');
|
|
526
|
+
validateAgentEvents(value.events, 'Agent event archive events');
|
|
527
|
+
return value as unknown as IAgentEventArchiveV2;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
export class AgentEventStoreConflictError extends Error {
|
|
531
|
+
public readonly sessionId: string;
|
|
532
|
+
public readonly expectedRevision: number;
|
|
533
|
+
public readonly actualRevision: number;
|
|
534
|
+
|
|
535
|
+
constructor(sessionId: string, expectedRevision: number, actualRevision: number) {
|
|
536
|
+
super(
|
|
537
|
+
`Agent event store conflict for session "${sessionId}": expected revision ${expectedRevision}, found ${actualRevision}.`,
|
|
538
|
+
);
|
|
539
|
+
this.name = 'AgentEventStoreConflictError';
|
|
540
|
+
this.sessionId = sessionId;
|
|
541
|
+
this.expectedRevision = expectedRevision;
|
|
542
|
+
this.actualRevision = actualRevision;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const clone = <TValue>(value: TValue): TValue => structuredClone(value);
|
|
547
|
+
|
|
548
|
+
const createSnapshot = (
|
|
549
|
+
sessionId: string,
|
|
550
|
+
revision: number,
|
|
551
|
+
events: readonly TAgentEvent[],
|
|
552
|
+
): IAgentEventSnapshotV2 => ({
|
|
553
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
554
|
+
sessionId,
|
|
555
|
+
revision,
|
|
556
|
+
updatedAt: Date.now(),
|
|
557
|
+
events: clone([...events]),
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
export class InMemoryAgentEventStore implements IAgentEventStoreV2 {
|
|
561
|
+
public readonly eventSchemaVersion = 2 as const;
|
|
562
|
+
private readonly snapshots = new Map<string, TAgentEventSnapshot>();
|
|
563
|
+
private readonly archives = new Map<string, TAgentEventArchive>();
|
|
564
|
+
|
|
565
|
+
public async load(sessionId: string): Promise<TAgentEventSnapshot | undefined> {
|
|
566
|
+
const snapshot = this.snapshots.get(sessionId);
|
|
567
|
+
return snapshot ? clone(snapshot) : undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
public async save(
|
|
571
|
+
sessionId: string,
|
|
572
|
+
events: readonly TAgentEvent[],
|
|
573
|
+
expectedRevision: number,
|
|
574
|
+
): Promise<number> {
|
|
575
|
+
const actualRevision = this.snapshots.get(sessionId)?.revision ?? 0;
|
|
576
|
+
if (actualRevision !== expectedRevision) {
|
|
577
|
+
throw new AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
|
|
578
|
+
}
|
|
579
|
+
const revision = expectedRevision + 1;
|
|
580
|
+
this.snapshots.set(sessionId, createSnapshot(sessionId, revision, events));
|
|
581
|
+
return revision;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
public async archive(archive: IAgentEventArchiveV2): Promise<void> {
|
|
585
|
+
const nextArchive = clone(archive);
|
|
586
|
+
const key = `${archive.sessionId}\n${archive.archiveId}`;
|
|
587
|
+
const existing = this.archives.get(key);
|
|
588
|
+
if (existing) {
|
|
589
|
+
const existingIds = existing.events.map((event) => event.id).join('\n');
|
|
590
|
+
const nextIds = nextArchive.events.map((event) => event.id).join('\n');
|
|
591
|
+
if (existingIds !== nextIds) {
|
|
592
|
+
throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.archives.set(key, nextArchive);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
public async loadArchive(
|
|
600
|
+
sessionId: string,
|
|
601
|
+
archiveId: string,
|
|
602
|
+
): Promise<TAgentEventArchive | undefined> {
|
|
603
|
+
const archive = this.archives.get(`${sessionId}\n${archiveId}`);
|
|
604
|
+
return archive ? clone(archive) : undefined;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export interface IFileAgentEventStoreOptions {
|
|
609
|
+
rootDir: string;
|
|
610
|
+
/** Maximum time spent waiting for another writer. Default: 10 seconds. */
|
|
611
|
+
lockTimeoutMs?: number;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const assertSessionId = (sessionId: string): void => {
|
|
615
|
+
if (!sessionId.trim()) throw new Error('Agent event store sessionId must not be empty.');
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const decodeSnapshot = (value: unknown, sessionId: string): TAgentEventSnapshot => {
|
|
619
|
+
const snapshot = value as Partial<TAgentEventSnapshot>;
|
|
620
|
+
if (snapshot?.schemaVersion === SNAPSHOT_SCHEMA_VERSION) {
|
|
621
|
+
try {
|
|
622
|
+
return validateAgentEventSnapshotV2(value, sessionId);
|
|
623
|
+
} catch {
|
|
624
|
+
throw new Error(`Invalid agent event snapshot for session "${sessionId}".`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (
|
|
628
|
+
!snapshot
|
|
629
|
+
|| snapshot.schemaVersion !== 1
|
|
630
|
+
|| snapshot.sessionId !== sessionId
|
|
631
|
+
|| !Number.isSafeInteger(snapshot.revision)
|
|
632
|
+
|| (snapshot.revision ?? -1) < 1
|
|
633
|
+
|| !Array.isArray(snapshot.events)
|
|
634
|
+
) {
|
|
635
|
+
throw new Error(`Invalid agent event snapshot for session "${sessionId}".`);
|
|
636
|
+
}
|
|
637
|
+
return snapshot as TAgentEventSnapshot;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const decodeArchive = (
|
|
641
|
+
value: unknown,
|
|
642
|
+
sessionId: string,
|
|
643
|
+
archiveId: string,
|
|
644
|
+
): TAgentEventArchive => {
|
|
645
|
+
const archive = value as Partial<TAgentEventArchive>;
|
|
646
|
+
if (archive?.schemaVersion === ARCHIVE_SCHEMA_VERSION) {
|
|
647
|
+
try {
|
|
648
|
+
return validateAgentEventArchiveV2(value, sessionId, archiveId);
|
|
649
|
+
} catch {
|
|
650
|
+
throw new Error(`Invalid agent event archive "${archiveId}" for session "${sessionId}".`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (
|
|
654
|
+
!archive
|
|
655
|
+
|| archive.schemaVersion !== 1
|
|
656
|
+
|| archive.sessionId !== sessionId
|
|
657
|
+
|| archive.archiveId !== archiveId
|
|
658
|
+
|| !Array.isArray(archive.events)
|
|
659
|
+
) {
|
|
660
|
+
throw new Error(`Invalid agent event archive "${archiveId}" for session "${sessionId}".`);
|
|
661
|
+
}
|
|
662
|
+
return archive as TAgentEventArchive;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const sleep = async (milliseconds: number): Promise<void> => {
|
|
666
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export class FileAgentEventStore implements IAgentEventStoreV2 {
|
|
670
|
+
public readonly eventSchemaVersion = 2 as const;
|
|
671
|
+
private readonly rootDir: string;
|
|
672
|
+
private readonly lockTimeoutMs: number;
|
|
673
|
+
|
|
674
|
+
constructor(options: IFileAgentEventStoreOptions) {
|
|
675
|
+
if (!options.rootDir.trim()) throw new Error('FileAgentEventStore rootDir must not be empty.');
|
|
676
|
+
this.rootDir = plugins.path.resolve(options.rootDir);
|
|
677
|
+
this.lockTimeoutMs = options.lockTimeoutMs ?? 10_000;
|
|
678
|
+
if (!Number.isSafeInteger(this.lockTimeoutMs) || this.lockTimeoutMs <= 0) {
|
|
679
|
+
throw new Error('lockTimeoutMs must be a positive integer.');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private sessionKey(sessionId: string): string {
|
|
684
|
+
assertSessionId(sessionId);
|
|
685
|
+
return plugins.crypto.createHash('sha256').update(sessionId).digest('hex');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private snapshotPath(sessionId: string): string {
|
|
689
|
+
return plugins.path.join(this.rootDir, 'sessions', `${this.sessionKey(sessionId)}.bin`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private archivePath(sessionId: string, archiveId: string): string {
|
|
693
|
+
if (!archiveId.trim()) throw new Error('Agent event archiveId must not be empty.');
|
|
694
|
+
const archiveKey = plugins.crypto.createHash('sha256').update(archiveId).digest('hex');
|
|
695
|
+
return plugins.path.join(this.rootDir, 'archives', this.sessionKey(sessionId), `${archiveKey}.bin`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async readValue(filePath: string): Promise<unknown | undefined> {
|
|
699
|
+
try {
|
|
700
|
+
return plugins.v8.deserialize(await plugins.fs.readFile(filePath));
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
703
|
+
throw error;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async syncDirectory(directoryPath: string): Promise<void> {
|
|
708
|
+
const handle = await plugins.fs.open(directoryPath, 'r');
|
|
709
|
+
try {
|
|
710
|
+
await handle.sync();
|
|
711
|
+
} finally {
|
|
712
|
+
await handle.close();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private async writeAtomic(filePath: string, value: unknown): Promise<void> {
|
|
717
|
+
const directoryPath = plugins.path.dirname(filePath);
|
|
718
|
+
await plugins.fs.mkdir(directoryPath, { recursive: true });
|
|
719
|
+
const temporaryPath = `${filePath}.${process.pid}.${plugins.crypto.randomUUID()}.tmp`;
|
|
720
|
+
let handle: Awaited<ReturnType<typeof plugins.fs.open>> | undefined;
|
|
721
|
+
try {
|
|
722
|
+
handle = await plugins.fs.open(temporaryPath, 'wx');
|
|
723
|
+
await handle.writeFile(plugins.v8.serialize(value));
|
|
724
|
+
await handle.sync();
|
|
725
|
+
await handle.close();
|
|
726
|
+
handle = undefined;
|
|
727
|
+
await plugins.fs.rename(temporaryPath, filePath);
|
|
728
|
+
await this.syncDirectory(directoryPath);
|
|
729
|
+
} finally {
|
|
730
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
731
|
+
await plugins.fs.rm(temporaryPath, { force: true });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private async withSessionLock<TValue>(
|
|
736
|
+
sessionId: string,
|
|
737
|
+
operation: () => Promise<TValue>,
|
|
738
|
+
): Promise<TValue> {
|
|
739
|
+
const lockPath = plugins.path.join(this.rootDir, 'locks', `${this.sessionKey(sessionId)}.lock`);
|
|
740
|
+
const ownerPath = plugins.path.join(lockPath, 'owner');
|
|
741
|
+
const ownerToken = plugins.crypto.randomUUID();
|
|
742
|
+
const ownerRecord = JSON.stringify({ token: ownerToken });
|
|
743
|
+
await plugins.fs.mkdir(plugins.path.dirname(lockPath), { recursive: true });
|
|
744
|
+
const startedAt = Date.now();
|
|
745
|
+
while (true) {
|
|
746
|
+
try {
|
|
747
|
+
await plugins.fs.mkdir(lockPath);
|
|
748
|
+
try {
|
|
749
|
+
await plugins.fs.writeFile(ownerPath, ownerRecord, 'utf8');
|
|
750
|
+
} catch (error) {
|
|
751
|
+
await plugins.fs.rm(lockPath, { recursive: true, force: true });
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
break;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
757
|
+
if (Date.now() - startedAt >= this.lockTimeoutMs) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Timed out waiting for the event-store lock for session "${sessionId}" at "${lockPath}".`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
await sleep(20);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
return await operation();
|
|
767
|
+
} finally {
|
|
768
|
+
let stillOwner = false;
|
|
769
|
+
try {
|
|
770
|
+
const owner = JSON.parse(await plugins.fs.readFile(ownerPath, 'utf8')) as { token?: unknown };
|
|
771
|
+
stillOwner = owner.token === ownerToken;
|
|
772
|
+
} catch {}
|
|
773
|
+
if (stillOwner) await plugins.fs.rm(lockPath, { recursive: true, force: true });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
public async load(sessionId: string): Promise<TAgentEventSnapshot | undefined> {
|
|
778
|
+
const value = await this.readValue(this.snapshotPath(sessionId));
|
|
779
|
+
return value === undefined ? undefined : decodeSnapshot(value, sessionId);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
public async save(
|
|
783
|
+
sessionId: string,
|
|
784
|
+
events: readonly TAgentEvent[],
|
|
785
|
+
expectedRevision: number,
|
|
786
|
+
): Promise<number> {
|
|
787
|
+
return this.withSessionLock(sessionId, async () => {
|
|
788
|
+
const filePath = this.snapshotPath(sessionId);
|
|
789
|
+
const currentValue = await this.readValue(filePath);
|
|
790
|
+
const actualRevision = currentValue === undefined
|
|
791
|
+
? 0
|
|
792
|
+
: decodeSnapshot(currentValue, sessionId).revision;
|
|
793
|
+
if (actualRevision !== expectedRevision) {
|
|
794
|
+
throw new AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
|
|
795
|
+
}
|
|
796
|
+
const revision = expectedRevision + 1;
|
|
797
|
+
await this.writeAtomic(filePath, createSnapshot(sessionId, revision, events));
|
|
798
|
+
return revision;
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
public async archive(archive: IAgentEventArchiveV2): Promise<void> {
|
|
803
|
+
const nextArchive = clone(archive);
|
|
804
|
+
await this.withSessionLock(archive.sessionId, async () => {
|
|
805
|
+
const filePath = this.archivePath(archive.sessionId, archive.archiveId);
|
|
806
|
+
const currentValue = await this.readValue(filePath);
|
|
807
|
+
if (currentValue !== undefined) {
|
|
808
|
+
const current = decodeArchive(currentValue, archive.sessionId, archive.archiveId);
|
|
809
|
+
const currentIds = current.events.map((event) => event.id).join('\n');
|
|
810
|
+
const nextIds = nextArchive.events.map((event) => event.id).join('\n');
|
|
811
|
+
if (currentIds !== nextIds) {
|
|
812
|
+
throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
|
|
813
|
+
}
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
await this.writeAtomic(filePath, nextArchive);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
public async loadArchive(
|
|
821
|
+
sessionId: string,
|
|
822
|
+
archiveId: string,
|
|
823
|
+
): Promise<TAgentEventArchive | undefined> {
|
|
824
|
+
const value = await this.readValue(this.archivePath(sessionId, archiveId));
|
|
825
|
+
return value === undefined ? undefined : decodeArchive(value, sessionId, archiveId);
|
|
826
|
+
}
|
|
827
|
+
}
|