@frockbot/kernel-contracts 0.3.4 → 0.3.6
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/package.json +1 -1
- package/src/model-invocation.ts +47 -0
- package/src/session.test.ts +26 -0
- package/src/session.ts +27 -7
- package/src/types.ts +57 -2
package/package.json
CHANGED
package/src/model-invocation.ts
CHANGED
|
@@ -14,6 +14,53 @@ export class LlmEffectNotStartedError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A model request that ran out of time.
|
|
19
|
+
*
|
|
20
|
+
* Two deadlines, because they fail differently. `first-byte` is a provider that
|
|
21
|
+
* accepted the request and said nothing: the request may well be running, so
|
|
22
|
+
* the outcome is uncertain and the run settles on that. `idle` is a stream that
|
|
23
|
+
* started and then stopped mid-answer, which is the same uncertainty arriving
|
|
24
|
+
* later, with words already on screen.
|
|
25
|
+
*
|
|
26
|
+
* Either is a real answer where before there was none: a Turn with no deadline
|
|
27
|
+
* anywhere hung for seventeen minutes showing nothing at all.
|
|
28
|
+
*/
|
|
29
|
+
export class ModelRequestDeadlineError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
readonly phase: "first-byte" | "idle",
|
|
32
|
+
readonly milliseconds: number,
|
|
33
|
+
) {
|
|
34
|
+
super(
|
|
35
|
+
phase === "first-byte"
|
|
36
|
+
? `Model request produced nothing within ${Math.round(milliseconds / 1000)}s`
|
|
37
|
+
: `Model response stalled for ${Math.round(milliseconds / 1000)}s`,
|
|
38
|
+
);
|
|
39
|
+
this.name = "ModelRequestDeadlineError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Deadlines a provider applies to one model request. */
|
|
44
|
+
export interface ModelRequestDeadlinesV1 {
|
|
45
|
+
/** Time allowed from sending the request to the first stream event. */
|
|
46
|
+
firstByteMs: number;
|
|
47
|
+
/** Time allowed between two stream events once the answer has started. */
|
|
48
|
+
idleMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The defaults every provider gets unless its Package names others.
|
|
53
|
+
*
|
|
54
|
+
* Two minutes to say anything at all is generous for a chat completion and
|
|
55
|
+
* still an order of magnitude inside the wall-clock a person will wait; the
|
|
56
|
+
* same allowance between chunks tolerates a slow tool-call assembly without
|
|
57
|
+
* tolerating a dead socket.
|
|
58
|
+
*/
|
|
59
|
+
export const MODEL_REQUEST_DEADLINES_V1: ModelRequestDeadlinesV1 = {
|
|
60
|
+
firstByteMs: 120_000,
|
|
61
|
+
idleMs: 120_000,
|
|
62
|
+
};
|
|
63
|
+
|
|
17
64
|
export type LlmReconciliationOutcome =
|
|
18
65
|
| {
|
|
19
66
|
status: "recovered";
|
package/src/session.test.ts
CHANGED
|
@@ -368,6 +368,32 @@ describe("SessionStore", () => {
|
|
|
368
368
|
]);
|
|
369
369
|
});
|
|
370
370
|
|
|
371
|
+
test("a failed durable write is reported and does not stop later writes", async () => {
|
|
372
|
+
const persisted: string[][] = [];
|
|
373
|
+
let failNext = true;
|
|
374
|
+
const root = await createStore(undefined, {
|
|
375
|
+
persistEvents: async (_sessionId, events) => {
|
|
376
|
+
await Promise.resolve();
|
|
377
|
+
if (failNext) {
|
|
378
|
+
failNext = false;
|
|
379
|
+
throw new Error("storage hiccup");
|
|
380
|
+
}
|
|
381
|
+
persisted.push(events.map((event) => event.type));
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const session = root.sessions.create("durable-session");
|
|
385
|
+
|
|
386
|
+
// The Turn fails loudly rather than carrying on in memory over a log that
|
|
387
|
+
// silently stopped being written.
|
|
388
|
+
await expect(session.flush()).rejects.toThrow("storage hiccup");
|
|
389
|
+
|
|
390
|
+
// And the chain is not poisoned: chaining with `then` alone made every
|
|
391
|
+
// later write skip its callback for the life of the Session.
|
|
392
|
+
session.append({ type: "turn/start", turn: 1 });
|
|
393
|
+
await session.flush().catch(() => undefined);
|
|
394
|
+
expect(persisted).toEqual([["turn/start"]]);
|
|
395
|
+
});
|
|
396
|
+
|
|
371
397
|
test("rehydrates a session and continues its sequence", async () => {
|
|
372
398
|
const firstRoot = await createStore();
|
|
373
399
|
const first = firstRoot.sessions.create("durable-session");
|
package/src/session.ts
CHANGED
|
@@ -210,6 +210,8 @@ export class Session {
|
|
|
210
210
|
#emit: (envelope: SessionEventEnvelope) => void;
|
|
211
211
|
#persist?: PersistSessionEvents;
|
|
212
212
|
#pendingPersistence: Promise<void> = Promise.resolve();
|
|
213
|
+
/** The first durable write that failed. Every later `flush` reports it. */
|
|
214
|
+
#persistFailure: Error | undefined;
|
|
213
215
|
/**
|
|
214
216
|
* Resolved attachment bytes, keyed by content hash, held only while this
|
|
215
217
|
* Session is resident.
|
|
@@ -273,15 +275,27 @@ export class Session {
|
|
|
273
275
|
for (const event of events) this.#emit({ sessionId: this.id, event });
|
|
274
276
|
if (this.#persist && events.length > 0) {
|
|
275
277
|
const durableEvents = structuredClone(events);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
// A chain that has already rejected must still attempt this write.
|
|
279
|
+
// Chaining with `then` alone skipped the callback for the life of the
|
|
280
|
+
// Session — no event was ever written again while the loop carried on
|
|
281
|
+
// in memory — and left the rejection unhandled between an append and
|
|
282
|
+
// the next flush. The failure is remembered instead, and `flush` throws
|
|
283
|
+
// it, so the Turn fails loudly exactly once.
|
|
284
|
+
const pending = this.#pendingPersistence
|
|
285
|
+
.catch(() => undefined)
|
|
286
|
+
.then(() => this.#persist?.(this.id, durableEvents));
|
|
287
|
+
this.#pendingPersistence = pending;
|
|
288
|
+
void pending.catch((error: unknown) => {
|
|
289
|
+
this.#persistFailure ??=
|
|
290
|
+
error instanceof Error ? error : new Error(String(error));
|
|
291
|
+
});
|
|
279
292
|
}
|
|
280
293
|
return events;
|
|
281
294
|
}
|
|
282
295
|
|
|
283
|
-
flush(): Promise<void> {
|
|
284
|
-
|
|
296
|
+
async flush(): Promise<void> {
|
|
297
|
+
await this.#pendingPersistence.catch(() => undefined);
|
|
298
|
+
if (this.#persistFailure) throw this.#persistFailure;
|
|
285
299
|
}
|
|
286
300
|
|
|
287
301
|
/**
|
|
@@ -410,10 +424,16 @@ export class Session {
|
|
|
410
424
|
});
|
|
411
425
|
}
|
|
412
426
|
}
|
|
427
|
+
// An unresolved model request holds the step open — but only while the run
|
|
428
|
+
// might still resume and let that outcome land. Closing the turn means it
|
|
429
|
+
// never will, and a `turn/end` over an open step is itself invalid: it
|
|
430
|
+
// produced "turn 1 ended while step 1 is open" and left the log as unusable
|
|
431
|
+
// as the open turn it was meant to repair.
|
|
413
432
|
if (
|
|
414
433
|
openStep &&
|
|
415
|
-
|
|
416
|
-
|
|
434
|
+
(closeTurn
|
|
435
|
+
? true
|
|
436
|
+
: unresolvedModelRequests.size === 0 && !openStepHasAssistant)
|
|
417
437
|
) {
|
|
418
438
|
repairs.push({ type: "step/end", ...openStep, outcome: "interrupted" });
|
|
419
439
|
}
|
package/src/types.ts
CHANGED
|
@@ -170,7 +170,12 @@ export function turnEndReason(value: unknown): string | undefined {
|
|
|
170
170
|
return bounded.length > 0 ? bounded : undefined;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* The failure text recorded against a Turn that did not complete. It names the
|
|
175
|
+
* outcome and the provider's own reason, which the debug surface and the API
|
|
176
|
+
* both need. It is a diagnostic, not copy: the client never renders it into the
|
|
177
|
+
* conversation — see `runFailureCopyV1` in the shell's client.
|
|
178
|
+
*/
|
|
174
179
|
export function turnFailureMessage(
|
|
175
180
|
outcome: TurnOutcome,
|
|
176
181
|
reason?: string,
|
|
@@ -370,6 +375,22 @@ export interface SessionEventMap {
|
|
|
370
375
|
contentHash: string;
|
|
371
376
|
generationId: string;
|
|
372
377
|
};
|
|
378
|
+
/**
|
|
379
|
+
* A recorded Package effect intent that ended without its outcome: the host
|
|
380
|
+
* refused, or the attempt threw. Every `package/*-intent` closes with either
|
|
381
|
+
* its outcome event or this one, so the session log never says an effect was
|
|
382
|
+
* intended and then falls silent about how it ended — which is exactly what
|
|
383
|
+
* the intent/outcome pair is for (finding F12).
|
|
384
|
+
*/
|
|
385
|
+
"package/effect-failed": {
|
|
386
|
+
turn: number;
|
|
387
|
+
step: number;
|
|
388
|
+
effectId: string;
|
|
389
|
+
effect: "author" | "undo" | "catalog-change";
|
|
390
|
+
reason: string;
|
|
391
|
+
/** The durable failure record, when the host wrote one. */
|
|
392
|
+
failureId?: string;
|
|
393
|
+
};
|
|
373
394
|
/** A Bot-isolate loop hook failed open for one invocation. */
|
|
374
395
|
"package/hook-failed": {
|
|
375
396
|
packageId: string;
|
|
@@ -1327,6 +1348,36 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
|
1327
1348
|
eventString(event.contentHash, "session event.contentHash");
|
|
1328
1349
|
eventString(event.generationId, "session event.generationId");
|
|
1329
1350
|
break;
|
|
1351
|
+
case "package/effect-failed":
|
|
1352
|
+
requireEventKeys(
|
|
1353
|
+
event,
|
|
1354
|
+
keys(
|
|
1355
|
+
"turn",
|
|
1356
|
+
"step",
|
|
1357
|
+
"effectId",
|
|
1358
|
+
"effect",
|
|
1359
|
+
"reason",
|
|
1360
|
+
...(Object.hasOwn(event, "failureId") ? ["failureId"] : []),
|
|
1361
|
+
),
|
|
1362
|
+
"session event",
|
|
1363
|
+
);
|
|
1364
|
+
turn();
|
|
1365
|
+
step();
|
|
1366
|
+
eventString(event.effectId, "session event.effectId");
|
|
1367
|
+
if (
|
|
1368
|
+
event.effect !== "author" &&
|
|
1369
|
+
event.effect !== "undo" &&
|
|
1370
|
+
event.effect !== "catalog-change"
|
|
1371
|
+
) {
|
|
1372
|
+
throw new Error(
|
|
1373
|
+
'session event.effect must be "author", "undo" or "catalog-change"',
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
eventString(event.reason, "session event.reason", true);
|
|
1377
|
+
if (Object.hasOwn(event, "failureId")) {
|
|
1378
|
+
eventString(event.failureId, "session event.failureId");
|
|
1379
|
+
}
|
|
1380
|
+
break;
|
|
1330
1381
|
case "package/hook-failed":
|
|
1331
1382
|
requireEventKeys(
|
|
1332
1383
|
event,
|
|
@@ -1478,7 +1529,11 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
|
1478
1529
|
const label = `session event.refusals[${index}]`;
|
|
1479
1530
|
const entry = eventRecord(refusal, label);
|
|
1480
1531
|
requireEventKeys(entry, ["path", "reason"], label);
|
|
1481
|
-
|
|
1532
|
+
// A refusal that names no path is still a refusal — the read was
|
|
1533
|
+
// declined at the root, and the reason is the part that matters.
|
|
1534
|
+
// Rejecting it here killed the Turn *after* the event was appended,
|
|
1535
|
+
// which left the durable log open inside that Turn and wedged the Bot.
|
|
1536
|
+
eventString(entry.path, `${label}.path`, true);
|
|
1482
1537
|
eventString(entry.reason, `${label}.reason`);
|
|
1483
1538
|
});
|
|
1484
1539
|
break;
|