@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 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 gated = toolCalls.filter((call) => mode.needsApproval(call.name));
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, `${createHash('sha256').update(key).digest('hex').slice(0, 32)}.json`);
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
- if (!Array.isArray(messages))
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
- return (await this.messages()).length;
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
- return this.store.withLock(this.key, async () => {
47
- const stored = (await this.store.get(this.key)) ?? {};
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 recordedAt = new Date().toISOString();
50
- const appended = messages.map((message, index) => ({
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
- await this.store.withLock(this.key, async () => {
72
- const stored = (await this.store.get(this.key)) ?? {};
73
- await this.store.put(this.key, { ...stored, messages: [] });
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-harness",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
5
5
  "type": "module",
6
6
  "sideEffects": false,