@particle-academy/prism-harness 0.6.0 → 0.8.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/README.md +21 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/runtime.js +10 -1
- package/dist/stores/file-session-store.d.ts +13 -2
- package/dist/stores/file-session-store.js +142 -2
- package/dist/stores/session-store.d.ts +28 -0
- package/dist/stores/session-store.js +5 -0
- package/dist/thread.js +62 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -202,6 +202,15 @@ reporting a constraint it did not actually check. The same rules as the PHP
|
|
|
202
202
|
reference, message for message, so a document refused in one language is refused
|
|
203
203
|
in the other for the same stated reason.
|
|
204
204
|
|
|
205
|
+
## Failed model steps
|
|
206
|
+
|
|
207
|
+
A client response with `finishReason: 'error'` or `'unknown'` ends the run with
|
|
208
|
+
`HarnessError.code === 'model_step_failed'`. Its `finishReason` identifies which
|
|
209
|
+
of those two finishes occurred; the error message contains no model output.
|
|
210
|
+
The step, cost, and assistant response are recorded before the error is raised.
|
|
211
|
+
Tools requested by that response are refused, with no pending approval authority,
|
|
212
|
+
so approving or resuming later cannot execute them. A new turn can still be sent.
|
|
213
|
+
|
|
205
214
|
## Approvals
|
|
206
215
|
|
|
207
216
|
A mode names the tools a person must approve:
|
|
@@ -341,6 +350,18 @@ badly.
|
|
|
341
350
|
as held, bounded by the file's own age so a process that died mid-write cannot
|
|
342
351
|
wedge the key either.
|
|
343
352
|
|
|
353
|
+
**Threads are append-only logs** (`<digest>.jsonl` beside the payload file):
|
|
354
|
+
one line per message, one write and an `fsync` per `record()`, so recording
|
|
355
|
+
costs the size of the new messages however long the thread already is.
|
|
356
|
+
Before 0.7.0, every message re-read and rewrote the whole conversation. A
|
|
357
|
+
thread stored that way is read as before and moved into the log the first
|
|
358
|
+
time it grows. A line cut off by a crash is dropped, and the next append
|
|
359
|
+
starts after the last complete line. A damaged line anywhere else is refused
|
|
360
|
+
with `unmappable_content` rather than silently skipped.
|
|
361
|
+
|
|
362
|
+
The thread log is **not yet shared with the PHP and Python ports**: a Python
|
|
363
|
+
worker pointed at the same directory does not read it.
|
|
364
|
+
|
|
344
365
|
Implement `SessionStore` for anything else. Declare your own `durability()`:
|
|
345
366
|
only you know whether your Redis is persistent or a disposable cache, and that
|
|
346
367
|
declaration is an assertion about your infrastructure, not a preference.
|
package/dist/errors.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type HarnessErrorCode =
|
|
|
18
18
|
| 'tool_not_available'
|
|
19
19
|
/** A run was refused — by budget, by depth, or by a cancelled ledger. */
|
|
20
20
|
| 'run_not_permitted'
|
|
21
|
+
/** A model returned a terminal error/unknown finish; its tools must not run. */
|
|
22
|
+
| 'model_step_failed'
|
|
21
23
|
/** A stored payload could not be mapped back into a value object. */
|
|
22
24
|
| 'unmappable_content'
|
|
23
25
|
/** A session was asked to do something that needs a runtime it does not have. */
|
|
@@ -75,6 +77,7 @@ export type HarnessErrorCode =
|
|
|
75
77
|
| 'structured_schema_violation';
|
|
76
78
|
export interface HarnessErrorOptions {
|
|
77
79
|
cause?: unknown;
|
|
80
|
+
finishReason?: 'error' | 'unknown';
|
|
78
81
|
/** The model's own text, on a structured failure. See `HarnessError.document`. */
|
|
79
82
|
document?: string;
|
|
80
83
|
/** Every way a document missed its schema, on a structured failure. */
|
|
@@ -82,6 +85,7 @@ export interface HarnessErrorOptions {
|
|
|
82
85
|
}
|
|
83
86
|
export declare class HarnessError extends Error {
|
|
84
87
|
readonly code: HarnessErrorCode;
|
|
88
|
+
readonly finishReason?: 'error' | 'unknown';
|
|
85
89
|
/**
|
|
86
90
|
* The model's own text, on a structured failure.
|
|
87
91
|
*
|
|
@@ -106,6 +110,7 @@ export declare class HarnessError extends Error {
|
|
|
106
110
|
static unknownStoreDriver(slot: string, driver: string): HarnessError;
|
|
107
111
|
static toolNotAvailable(name: string, available: readonly string[]): HarnessError;
|
|
108
112
|
static runNotPermitted(reason: string): HarnessError;
|
|
113
|
+
static modelStepFailed(finishReason: 'error' | 'unknown'): HarnessError;
|
|
109
114
|
static unmappableContent(description: string, options?: HarnessErrorOptions): HarnessError;
|
|
110
115
|
static modeNotConfigured(name: string): HarnessError;
|
|
111
116
|
static modeMalformed(name: string, detail: string): HarnessError;
|
package/dist/errors.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export class HarnessError extends Error {
|
|
2
2
|
code;
|
|
3
|
+
finishReason;
|
|
3
4
|
/**
|
|
4
5
|
* The model's own text, on a structured failure.
|
|
5
6
|
*
|
|
@@ -14,6 +15,10 @@ export class HarnessError extends Error {
|
|
|
14
15
|
super(message, options);
|
|
15
16
|
this.name = 'HarnessError';
|
|
16
17
|
this.code = code;
|
|
18
|
+
if (options.finishReason !== undefined && options.finishReason !== 'error' && options.finishReason !== 'unknown') {
|
|
19
|
+
throw new TypeError('Model step finish reason must be error or unknown');
|
|
20
|
+
}
|
|
21
|
+
this.finishReason = options.finishReason;
|
|
17
22
|
this.document = options.document;
|
|
18
23
|
this.problems = options.problems ?? [];
|
|
19
24
|
}
|
|
@@ -44,6 +49,12 @@ export class HarnessError extends Error {
|
|
|
44
49
|
static runNotPermitted(reason) {
|
|
45
50
|
return new HarnessError('run_not_permitted', reason);
|
|
46
51
|
}
|
|
52
|
+
static modelStepFailed(finishReason) {
|
|
53
|
+
if (finishReason !== 'error' && finishReason !== 'unknown') {
|
|
54
|
+
throw new TypeError('Model step finish reason must be error or unknown');
|
|
55
|
+
}
|
|
56
|
+
return new HarnessError('model_step_failed', `Model step failed: ${finishReason}.`, { finishReason });
|
|
57
|
+
}
|
|
47
58
|
static unmappableContent(description, options = {}) {
|
|
48
59
|
return new HarnessError('unmappable_content', `Could not rebuild stored content: ${description}.`, options);
|
|
49
60
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { HarnessError } from './errors.js';
|
|
|
2
2
|
export type { HarnessErrorCode, HarnessErrorOptions } from './errors.js';
|
|
3
3
|
export type { JsonObject, JsonValue } from './json.js';
|
|
4
4
|
export { isJsonObject } from './json.js';
|
|
5
|
-
export type { Durability, SessionStore } from './stores/session-store.js';
|
|
6
|
-
export { isDurable } from './stores/session-store.js';
|
|
5
|
+
export type { AppendLogStore, Durability, SessionStore } from './stores/session-store.js';
|
|
6
|
+
export { isDurable, supportsAppendLog } from './stores/session-store.js';
|
|
7
7
|
export { MemorySessionStore } from './stores/memory-session-store.js';
|
|
8
8
|
export { FileSessionStore } from './stores/file-session-store.js';
|
|
9
9
|
export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { HarnessError } from './errors.js';
|
|
2
2
|
export { isJsonObject } from './json.js';
|
|
3
|
-
export { isDurable } from './stores/session-store.js';
|
|
3
|
+
export { isDurable, supportsAppendLog } from './stores/session-store.js';
|
|
4
4
|
export { MemorySessionStore } from './stores/memory-session-store.js';
|
|
5
5
|
export { FileSessionStore } from './stores/file-session-store.js';
|
|
6
6
|
export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
|
package/dist/runtime.js
CHANGED
|
@@ -171,7 +171,8 @@ export class AgentRuntime {
|
|
|
171
171
|
text = response.text;
|
|
172
172
|
finishReason = response.finishReason;
|
|
173
173
|
const toolCalls = response.toolCalls ?? [];
|
|
174
|
-
const
|
|
174
|
+
const failedFinish = finishReason === 'error' || finishReason === 'unknown' ? finishReason : null;
|
|
175
|
+
const gated = failedFinish === null ? toolCalls.filter((call) => mode.needsApproval(call.name)) : [];
|
|
175
176
|
const requests = gated.map((call) => ({
|
|
176
177
|
approval_id: `apr_${randomUUID().replaceAll('-', '')}`,
|
|
177
178
|
tool_call_id: call.id,
|
|
@@ -184,6 +185,14 @@ export class AgentRuntime {
|
|
|
184
185
|
? { ...(response.additionalContent ?? {}), structured: response.structured ?? null }
|
|
185
186
|
: response.additionalContent;
|
|
186
187
|
await thread.record([assistantRow(response.text, toolCalls, metadata, requests)], runId);
|
|
188
|
+
if (failedFinish !== null) {
|
|
189
|
+
// Complete the transcript with refusals, never approval requests, so a
|
|
190
|
+
// later resume cannot dispatch calls from this failed response.
|
|
191
|
+
if (toolCalls.length > 0) {
|
|
192
|
+
await thread.record([toolResultRow(toolCalls.map(call => toolResultEntry(call, `Not run: model step failed (${failedFinish}).`)))], runId);
|
|
193
|
+
}
|
|
194
|
+
throw HarnessError.modelStepFailed(failedFinish);
|
|
195
|
+
}
|
|
187
196
|
if (toolCalls.length === 0) {
|
|
188
197
|
if (schema !== undefined) {
|
|
189
198
|
// Recorded first, then checked: the exchange happened either way, and
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { JsonObject } from '../json.js';
|
|
2
|
-
import type { Durability, SessionStore } from './session-store.js';
|
|
2
|
+
import type { AppendLogStore, Durability, SessionStore } from './session-store.js';
|
|
3
3
|
/**
|
|
4
4
|
* State on disk, as one JSON file per key. DURABLE, and it says so.
|
|
5
5
|
*
|
|
@@ -23,12 +23,23 @@ import type { Durability, SessionStore } from './session-store.js';
|
|
|
23
23
|
* other. Two workers on different machines sharing a network filesystem do NOT
|
|
24
24
|
* reliably, and no file lock can promise that; use a database or Redis there.
|
|
25
25
|
*/
|
|
26
|
-
export declare class FileSessionStore implements SessionStore {
|
|
26
|
+
export declare class FileSessionStore implements SessionStore, AppendLogStore {
|
|
27
27
|
#private;
|
|
28
28
|
constructor(directory: string);
|
|
29
29
|
durability(): Durability;
|
|
30
30
|
get(key: string): Promise<JsonObject | null>;
|
|
31
31
|
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
32
32
|
forget(key: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* One JSON document per line, appended through a single write and then
|
|
35
|
+
* synced, so a message that `record()` returned is on disk. Before writing,
|
|
36
|
+
* a TORN tail (a previous write cut off by a crash) is truncated back to the
|
|
37
|
+
* last complete line; appending after it would glue the new line onto the
|
|
38
|
+
* fragment and corrupt both.
|
|
39
|
+
*/
|
|
40
|
+
appendLog(key: string, entries: readonly JsonObject[]): Promise<void>;
|
|
41
|
+
readLog(key: string): Promise<JsonObject[] | null>;
|
|
42
|
+
logLength(key: string): Promise<number | null>;
|
|
43
|
+
clearLog(key: string): Promise<void>;
|
|
33
44
|
withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
34
45
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, truncate, unlink, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isJsonObject } from '../json.js';
|
|
6
6
|
import { HarnessError } from '../errors.js';
|
|
@@ -29,6 +29,14 @@ import { HarnessError } from '../errors.js';
|
|
|
29
29
|
*/
|
|
30
30
|
export class FileSessionStore {
|
|
31
31
|
#directory;
|
|
32
|
+
/**
|
|
33
|
+
* What this instance last saw of each log: its size on disk and how many
|
|
34
|
+
* complete entries that size holds. Lets `logLength()` answer without reading
|
|
35
|
+
* the log, and stays honest across processes because it is only trusted while
|
|
36
|
+
* the file is still exactly that size. Another writer changes the size, and
|
|
37
|
+
* the next call recounts.
|
|
38
|
+
*/
|
|
39
|
+
#logs = new Map();
|
|
32
40
|
constructor(directory) {
|
|
33
41
|
this.#directory = directory;
|
|
34
42
|
mkdirSync(directory, { recursive: true });
|
|
@@ -87,6 +95,130 @@ export class FileSessionStore {
|
|
|
87
95
|
}
|
|
88
96
|
async forget(key) {
|
|
89
97
|
await rm(this.#pathFor(key), { force: true });
|
|
98
|
+
await rm(this.#logPathFor(key), { force: true });
|
|
99
|
+
this.#logs.delete(key);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* One JSON document per line, appended through a single write and then
|
|
103
|
+
* synced, so a message that `record()` returned is on disk. Before writing,
|
|
104
|
+
* a TORN tail (a previous write cut off by a crash) is truncated back to the
|
|
105
|
+
* last complete line; appending after it would glue the new line onto the
|
|
106
|
+
* fragment and corrupt both.
|
|
107
|
+
*/
|
|
108
|
+
async appendLog(key, entries) {
|
|
109
|
+
const path = this.#logPathFor(key);
|
|
110
|
+
await mkdir(this.#directory, { recursive: true });
|
|
111
|
+
const before = await this.#repairTail(path);
|
|
112
|
+
const data = entries.map((entry) => `${JSON.stringify(entry)}\n`).join('');
|
|
113
|
+
const handle = await open(path, 'a');
|
|
114
|
+
try {
|
|
115
|
+
if (data !== '')
|
|
116
|
+
await handle.write(data);
|
|
117
|
+
await handle.sync();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await handle.close();
|
|
121
|
+
}
|
|
122
|
+
const known = this.#logs.get(key);
|
|
123
|
+
if (known !== undefined && known.size === before) {
|
|
124
|
+
this.#logs.set(key, { size: before + Buffer.byteLength(data), count: known.count + entries.length });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.#logs.delete(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async readLog(key) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = await readFile(this.#logPathFor(key), 'utf8');
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (isMissing(error))
|
|
137
|
+
return null;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const lines = raw.split('\n');
|
|
141
|
+
// Everything after the last newline is a line that never finished. Empty
|
|
142
|
+
// when the file ends cleanly, a fragment when a crash cut a write short.
|
|
143
|
+
lines.pop();
|
|
144
|
+
const entries = lines.map((line, index) => {
|
|
145
|
+
let entry;
|
|
146
|
+
try {
|
|
147
|
+
entry = JSON.parse(line);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw HarnessError.unmappableContent(`line ${index + 1} of the thread log for [${key}] is not valid JSON`, {
|
|
151
|
+
cause: error,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (!isJsonObject(entry)) {
|
|
155
|
+
throw HarnessError.unmappableContent(`line ${index + 1} of the thread log for [${key}] is not an object`);
|
|
156
|
+
}
|
|
157
|
+
return entry;
|
|
158
|
+
});
|
|
159
|
+
this.#logs.set(key, { size: Buffer.byteLength(raw), count: entries.length });
|
|
160
|
+
return entries;
|
|
161
|
+
}
|
|
162
|
+
async logLength(key) {
|
|
163
|
+
let size;
|
|
164
|
+
try {
|
|
165
|
+
size = (await stat(this.#logPathFor(key))).size;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (isMissing(error))
|
|
169
|
+
return null;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const known = this.#logs.get(key);
|
|
173
|
+
if (known !== undefined && known.size === size)
|
|
174
|
+
return known.count;
|
|
175
|
+
return (await this.readLog(key))?.length ?? null;
|
|
176
|
+
}
|
|
177
|
+
async clearLog(key) {
|
|
178
|
+
await mkdir(this.#directory, { recursive: true });
|
|
179
|
+
await writeFile(this.#logPathFor(key), '', 'utf8');
|
|
180
|
+
this.#logs.set(key, { size: 0, count: 0 });
|
|
181
|
+
}
|
|
182
|
+
/** Cut a torn tail back to the last newline. Returns the file's size afterwards. */
|
|
183
|
+
async #repairTail(path) {
|
|
184
|
+
let size;
|
|
185
|
+
try {
|
|
186
|
+
size = (await stat(path)).size;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (isMissing(error))
|
|
190
|
+
return 0;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
if (size === 0)
|
|
194
|
+
return 0;
|
|
195
|
+
const handle = await open(path, 'r');
|
|
196
|
+
try {
|
|
197
|
+
const last = Buffer.alloc(1);
|
|
198
|
+
await handle.read(last, 0, 1, size - 1);
|
|
199
|
+
if (last[0] === 0x0a)
|
|
200
|
+
return size;
|
|
201
|
+
// Walk back a chunk at a time: one message can be larger than any chunk.
|
|
202
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
203
|
+
let end = size;
|
|
204
|
+
while (end > 0) {
|
|
205
|
+
const start = Math.max(0, end - chunk.length);
|
|
206
|
+
const { bytesRead } = await handle.read(chunk, 0, end - start, start);
|
|
207
|
+
const newline = chunk.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
208
|
+
if (newline !== -1) {
|
|
209
|
+
await handle.close();
|
|
210
|
+
await truncate(path, start + newline + 1);
|
|
211
|
+
return start + newline + 1;
|
|
212
|
+
}
|
|
213
|
+
end = start;
|
|
214
|
+
}
|
|
215
|
+
await handle.close();
|
|
216
|
+
await truncate(path, 0);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await handle.close().catch(() => undefined);
|
|
221
|
+
}
|
|
90
222
|
}
|
|
91
223
|
async withLock(key, callback, ttlSeconds = 10, waitSeconds = 5) {
|
|
92
224
|
const lockPath = `${this.#pathFor(key)}.lock`;
|
|
@@ -237,7 +369,11 @@ export class FileSessionStore {
|
|
|
237
369
|
* the mapping stays inspectable.
|
|
238
370
|
*/
|
|
239
371
|
#pathFor(key) {
|
|
240
|
-
return join(this.#directory, `${
|
|
372
|
+
return join(this.#directory, `${digestOf(key)}.json`);
|
|
373
|
+
}
|
|
374
|
+
/** The key's append-only log, beside its payload: same digest, `.jsonl`. */
|
|
375
|
+
#logPathFor(key) {
|
|
376
|
+
return join(this.#directory, `${digestOf(key)}.jsonl`);
|
|
241
377
|
}
|
|
242
378
|
}
|
|
243
379
|
/**
|
|
@@ -251,6 +387,10 @@ export class FileSessionStore {
|
|
|
251
387
|
* else.
|
|
252
388
|
*/
|
|
253
389
|
const STAMP = /^\d+\n$/;
|
|
390
|
+
/** A key's file name. Colons are illegal on Windows and keys run long, so it is digested. */
|
|
391
|
+
function digestOf(key) {
|
|
392
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 32);
|
|
393
|
+
}
|
|
254
394
|
function stamp(expiresAt) {
|
|
255
395
|
return `${String(Math.trunc(expiresAt))}\n`;
|
|
256
396
|
}
|
|
@@ -63,3 +63,31 @@ export interface SessionStore {
|
|
|
63
63
|
*/
|
|
64
64
|
durability(): Durability;
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* OPTIONAL: an append-only log per key, which a store may offer for threads.
|
|
68
|
+
*
|
|
69
|
+
* A thread only ever grows at the end, and through `get`/`put` every new
|
|
70
|
+
* message costs a read, parse, serialise and rewrite of the WHOLE conversation.
|
|
71
|
+
* That is quadratic over a session: Genie 2 measured a 9 MB thread being
|
|
72
|
+
* rewritten for every chat row. A store that can append says so by
|
|
73
|
+
* implementing this, and `Thread` then writes one line per message. A store
|
|
74
|
+
* that does not keeps working through `get`/`put` exactly as before.
|
|
75
|
+
*
|
|
76
|
+
* Every method is called with the key's `withLock` held by the caller.
|
|
77
|
+
*/
|
|
78
|
+
export interface AppendLogStore {
|
|
79
|
+
/** Durably append entries in order. One line each; nothing earlier is rewritten. */
|
|
80
|
+
appendLog(key: string, entries: readonly JsonObject[]): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Every complete entry, or null when no log exists for the key.
|
|
83
|
+
*
|
|
84
|
+
* A torn LAST line (a write cut off by a crash) is dropped. A damaged line
|
|
85
|
+
* anywhere else is history that cannot be read, and is refused loudly.
|
|
86
|
+
*/
|
|
87
|
+
readLog(key: string): Promise<JsonObject[] | null>;
|
|
88
|
+
/** How many complete entries, or null when no log exists. Need not read them all. */
|
|
89
|
+
logLength(key: string): Promise<number | null>;
|
|
90
|
+
/** Drop every entry; the log then exists and is empty. */
|
|
91
|
+
clearLog(key: string): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
export declare function supportsAppendLog(store: SessionStore): store is SessionStore & AppendLogStore;
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export function isDurable(durability) {
|
|
2
2
|
return durability === 'durable';
|
|
3
3
|
}
|
|
4
|
+
export function supportsAppendLog(store) {
|
|
5
|
+
const candidate = store;
|
|
6
|
+
return typeof candidate.appendLog === 'function' && typeof candidate.readLog === 'function'
|
|
7
|
+
&& typeof candidate.logLength === 'function' && typeof candidate.clearLog === 'function';
|
|
8
|
+
}
|
package/dist/thread.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isJsonObject } from './json.js';
|
|
2
2
|
import { HarnessError } from './errors.js';
|
|
3
|
+
import { supportsAppendLog } from './stores/session-store.js';
|
|
3
4
|
/**
|
|
4
5
|
* The stored conversation a session is bound to.
|
|
5
6
|
*
|
|
@@ -23,17 +24,29 @@ export class Thread {
|
|
|
23
24
|
this.key = key;
|
|
24
25
|
}
|
|
25
26
|
async messages() {
|
|
27
|
+
if (supportsAppendLog(this.store)) {
|
|
28
|
+
const log = await this.store.readLog(this.key);
|
|
29
|
+
if (log !== null)
|
|
30
|
+
return log.map((entry) => this.#toMessage(entry));
|
|
31
|
+
}
|
|
32
|
+
return (await this.#stored()).map((entry) => this.#toMessage(entry));
|
|
33
|
+
}
|
|
34
|
+
/** The whole-payload form: every store before the log, and any store without one. */
|
|
35
|
+
async #stored() {
|
|
26
36
|
const stored = await this.store.get(this.key);
|
|
27
37
|
if (stored === null)
|
|
28
38
|
return [];
|
|
29
39
|
const messages = stored.messages;
|
|
30
|
-
|
|
31
|
-
return [];
|
|
32
|
-
return messages.filter(isJsonObject).map((entry) => this.#toMessage(entry));
|
|
40
|
+
return Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
33
41
|
}
|
|
34
42
|
/** How many messages the thread holds. */
|
|
35
43
|
async count() {
|
|
36
|
-
|
|
44
|
+
if (supportsAppendLog(this.store)) {
|
|
45
|
+
const length = await this.store.logLength(this.key);
|
|
46
|
+
if (length !== null)
|
|
47
|
+
return length;
|
|
48
|
+
}
|
|
49
|
+
return (await this.#stored()).length;
|
|
37
50
|
}
|
|
38
51
|
/**
|
|
39
52
|
* Append messages, in order, and return them with their assigned positions.
|
|
@@ -43,17 +56,20 @@ export class Thread {
|
|
|
43
56
|
async record(messages, runId = null) {
|
|
44
57
|
if (messages.length === 0)
|
|
45
58
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
const store = this.store;
|
|
60
|
+
if (supportsAppendLog(store)) {
|
|
61
|
+
return store.withLock(this.key, async () => {
|
|
62
|
+
const length = (await store.logLength(this.key)) ?? (await this.#migrate(store));
|
|
63
|
+
const appended = this.#entries(messages, length, runId);
|
|
64
|
+
await store.appendLog(this.key, appended);
|
|
65
|
+
return appended.map((entry) => this.#toMessage(entry));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return store.withLock(this.key, async () => {
|
|
69
|
+
const stored = (await store.get(this.key)) ?? {};
|
|
48
70
|
const existing = Array.isArray(stored.messages) ? stored.messages.filter(isJsonObject) : [];
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
position: existing.length + index + 1,
|
|
52
|
-
message,
|
|
53
|
-
run_id: runId,
|
|
54
|
-
recorded_at: recordedAt,
|
|
55
|
-
}));
|
|
56
|
-
await this.store.put(this.key, {
|
|
71
|
+
const appended = this.#entries(messages, existing.length, runId);
|
|
72
|
+
await store.put(this.key, {
|
|
57
73
|
...stored,
|
|
58
74
|
messages: [...existing, ...appended],
|
|
59
75
|
});
|
|
@@ -68,11 +84,40 @@ export class Thread {
|
|
|
68
84
|
* side effect of clearing session state.
|
|
69
85
|
*/
|
|
70
86
|
async clear() {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
const store = this.store;
|
|
88
|
+
await store.withLock(this.key, async () => {
|
|
89
|
+
if (supportsAppendLog(store))
|
|
90
|
+
await store.clearLog(this.key);
|
|
91
|
+
const stored = await store.get(this.key);
|
|
92
|
+
if (stored !== null)
|
|
93
|
+
await store.put(this.key, { ...stored, messages: [] });
|
|
74
94
|
});
|
|
75
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Move a whole-payload thread into the log, once, the first time it grows.
|
|
98
|
+
*
|
|
99
|
+
* The log is written FIRST and the payload emptied after it. A crash between
|
|
100
|
+
* the two leaves both, and the log wins on every read, so nothing is lost
|
|
101
|
+
* and nothing is counted twice. Returns how many entries the log now holds.
|
|
102
|
+
*/
|
|
103
|
+
async #migrate(store) {
|
|
104
|
+
const existing = await this.#stored();
|
|
105
|
+
await store.appendLog(this.key, existing);
|
|
106
|
+
if (existing.length > 0) {
|
|
107
|
+
const stored = (await store.get(this.key)) ?? {};
|
|
108
|
+
await store.put(this.key, { ...stored, messages: [] });
|
|
109
|
+
}
|
|
110
|
+
return existing.length;
|
|
111
|
+
}
|
|
112
|
+
#entries(messages, length, runId) {
|
|
113
|
+
const recordedAt = new Date().toISOString();
|
|
114
|
+
return messages.map((message, index) => ({
|
|
115
|
+
position: length + index + 1,
|
|
116
|
+
message,
|
|
117
|
+
run_id: runId,
|
|
118
|
+
recorded_at: recordedAt,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
76
121
|
#toMessage(entry) {
|
|
77
122
|
const message = entry.message;
|
|
78
123
|
if (!isJsonObject(message)) {
|