@frockbot/kernel-contracts 0.3.5 → 0.3.7
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/index.ts +1 -0
- package/src/model-invocation.ts +61 -0
- package/src/remote.test.ts +70 -0
- package/src/remote.ts +76 -0
- package/src/session.ts +8 -2
- package/src/types.ts +78 -8
- package/src/workspace.ts +12 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./isolate-context-catalog.generated.js";
|
|
|
7
7
|
export * from "./loop-events.js";
|
|
8
8
|
export * from "./model-invocation.js";
|
|
9
9
|
export * from "./prompt-assembly.js";
|
|
10
|
+
export * from "./remote.js";
|
|
10
11
|
export * from "./send-to-user.js";
|
|
11
12
|
export * from "./session.js";
|
|
12
13
|
export * from "./skills.js";
|
package/src/model-invocation.ts
CHANGED
|
@@ -14,14 +14,75 @@ 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";
|
|
20
67
|
events: readonly LlmStreamEvent[];
|
|
21
68
|
}
|
|
22
69
|
| {
|
|
70
|
+
/**
|
|
71
|
+
* The effect may still exist at the provider but cannot be read right
|
|
72
|
+
* now. The run parks and can be reconciled again later.
|
|
73
|
+
*/
|
|
23
74
|
status: "unavailable";
|
|
24
75
|
reason: string;
|
|
76
|
+
}
|
|
77
|
+
| {
|
|
78
|
+
/**
|
|
79
|
+
* The provider keeps no durable copy of this effect, so no later attempt
|
|
80
|
+
* can do better. The run settles as a failure — with whatever text was
|
|
81
|
+
* already journaled preserved — rather than parking forever on a
|
|
82
|
+
* retrieval that will never succeed.
|
|
83
|
+
*/
|
|
84
|
+
status: "not-retrievable";
|
|
85
|
+
reason: string;
|
|
25
86
|
};
|
|
26
87
|
|
|
27
88
|
export interface LlmReconciliationCapability {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
RemoteCallTimeoutError,
|
|
4
|
+
remoteCallV1,
|
|
5
|
+
retryOnceV1,
|
|
6
|
+
withDeadlineV1,
|
|
7
|
+
} from "./remote.js";
|
|
8
|
+
|
|
9
|
+
describe("a remote call is bounded", () => {
|
|
10
|
+
test("a call that never answers is abandoned, not waited on", async () => {
|
|
11
|
+
const signals: AbortSignal[] = [];
|
|
12
|
+
const call = await withDeadlineV1(
|
|
13
|
+
"the ledger",
|
|
14
|
+
(signal) => {
|
|
15
|
+
signals.push(signal);
|
|
16
|
+
return new Promise<never>(() => {});
|
|
17
|
+
},
|
|
18
|
+
10,
|
|
19
|
+
).catch((error: unknown) => error);
|
|
20
|
+
|
|
21
|
+
expect(call).toBeInstanceOf(RemoteCallTimeoutError);
|
|
22
|
+
expect((call as Error).message).toContain("the ledger");
|
|
23
|
+
// The binding is told, even though the deadline is a bound on waiting and
|
|
24
|
+
// not a guarantee that the effect did not land.
|
|
25
|
+
expect(signals[0]!.aborted).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("an answer inside the deadline is returned unchanged", async () => {
|
|
29
|
+
expect(
|
|
30
|
+
await withDeadlineV1("the ledger", () => Promise.resolve(7), 1_000),
|
|
31
|
+
).toBe(7);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a transient failure is tried once more, and only once", async () => {
|
|
35
|
+
let attempts = 0;
|
|
36
|
+
expect(
|
|
37
|
+
await retryOnceV1(() => {
|
|
38
|
+
attempts += 1;
|
|
39
|
+
return attempts === 1
|
|
40
|
+
? Promise.reject(new Error("blip"))
|
|
41
|
+
: Promise.resolve("second");
|
|
42
|
+
}),
|
|
43
|
+
).toBe("second");
|
|
44
|
+
expect(attempts).toBe(2);
|
|
45
|
+
|
|
46
|
+
let always = 0;
|
|
47
|
+
await expect(
|
|
48
|
+
retryOnceV1(() => {
|
|
49
|
+
always += 1;
|
|
50
|
+
return Promise.reject(new Error("really down"));
|
|
51
|
+
}),
|
|
52
|
+
).rejects.toThrow("really down");
|
|
53
|
+
expect(always).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a hung call is retried under its own fresh deadline", async () => {
|
|
57
|
+
let attempts = 0;
|
|
58
|
+
await expect(
|
|
59
|
+
remoteCallV1(
|
|
60
|
+
"the memory index",
|
|
61
|
+
() => {
|
|
62
|
+
attempts += 1;
|
|
63
|
+
return new Promise<never>(() => {});
|
|
64
|
+
},
|
|
65
|
+
10,
|
|
66
|
+
),
|
|
67
|
+
).rejects.toBeInstanceOf(RemoteCallTimeoutError);
|
|
68
|
+
expect(attempts).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds on a remote call.
|
|
3
|
+
*
|
|
4
|
+
* `MEMORY_FILES` and `MEMORY_INDEX` are remote bindings even in development,
|
|
5
|
+
* and so are the cross-Durable-Object ledger and membership calls beside them.
|
|
6
|
+
* Every one of those seams used to be a bare `await`: no deadline, no retry.
|
|
7
|
+
* A hung binding therefore hung the whole Turn to the platform limit, and a
|
|
8
|
+
* blip that a second attempt would have survived failed a Turn instead.
|
|
9
|
+
*
|
|
10
|
+
* These are two small functions rather than a client wrapper on purpose. The
|
|
11
|
+
* seams are in several Packages and take several shapes, and what they all
|
|
12
|
+
* need is the same two sentences: do not wait forever, and try a transient
|
|
13
|
+
* failure once more.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How long one remote call may take before it is abandoned. */
|
|
17
|
+
export const REMOTE_CALL_TIMEOUT_MS_V1 = 10_000;
|
|
18
|
+
|
|
19
|
+
export class RemoteCallTimeoutError extends Error {
|
|
20
|
+
constructor(label: string, timeoutMs: number) {
|
|
21
|
+
super(`${label} did not answer within ${timeoutMs}ms`);
|
|
22
|
+
this.name = "RemoteCallTimeoutError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs one remote call under a deadline.
|
|
28
|
+
*
|
|
29
|
+
* The deadline is a bound on *waiting*, not a cancellation: a binding that
|
|
30
|
+
* ignores its `AbortSignal` may still land its effect, which is why every
|
|
31
|
+
* caller of this treats a timeout the way it treats any other uncertain
|
|
32
|
+
* outcome rather than assuming nothing happened.
|
|
33
|
+
*/
|
|
34
|
+
export async function withDeadlineV1<T>(
|
|
35
|
+
label: string,
|
|
36
|
+
call: (signal: AbortSignal) => Promise<T>,
|
|
37
|
+
timeoutMs: number = REMOTE_CALL_TIMEOUT_MS_V1,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
reject(new RemoteCallTimeoutError(label, timeoutMs));
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
return await Promise.race([call(controller.signal), expiry]);
|
|
49
|
+
} finally {
|
|
50
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs a call, and runs it once more if the first attempt threw.
|
|
56
|
+
*
|
|
57
|
+
* One retry, not a backoff schedule: the caller is inside a Turn a person is
|
|
58
|
+
* waiting on, and the failure this recovers from is a blip. Anything that
|
|
59
|
+
* fails twice is a real failure and is reported as one.
|
|
60
|
+
*/
|
|
61
|
+
export async function retryOnceV1<T>(call: () => Promise<T>): Promise<T> {
|
|
62
|
+
try {
|
|
63
|
+
return await call();
|
|
64
|
+
} catch {
|
|
65
|
+
return call();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A remote call under a deadline, attempted twice. */
|
|
70
|
+
export function remoteCallV1<T>(
|
|
71
|
+
label: string,
|
|
72
|
+
call: (signal: AbortSignal) => Promise<T>,
|
|
73
|
+
timeoutMs: number = REMOTE_CALL_TIMEOUT_MS_V1,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
return retryOnceV1(() => withDeadlineV1(label, call, timeoutMs));
|
|
76
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -424,10 +424,16 @@ export class Session {
|
|
|
424
424
|
});
|
|
425
425
|
}
|
|
426
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.
|
|
427
432
|
if (
|
|
428
433
|
openStep &&
|
|
429
|
-
|
|
430
|
-
|
|
434
|
+
(closeTurn
|
|
435
|
+
? true
|
|
436
|
+
: unresolvedModelRequests.size === 0 && !openStepHasAssistant)
|
|
431
437
|
) {
|
|
432
438
|
repairs.push({ type: "step/end", ...openStep, outcome: "interrupted" });
|
|
433
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;
|
|
@@ -493,7 +514,8 @@ export interface SessionEventMap {
|
|
|
493
514
|
facts: Array<{
|
|
494
515
|
scope: MemoryScopeNameV1;
|
|
495
516
|
projectId: string;
|
|
496
|
-
tier
|
|
517
|
+
/** The tier it was written as; a note lives in the log file. */
|
|
518
|
+
tier: "profile" | "log" | "note";
|
|
497
519
|
via: string;
|
|
498
520
|
learnedAt: string;
|
|
499
521
|
text: string;
|
|
@@ -527,7 +549,14 @@ export interface SessionEventMap {
|
|
|
527
549
|
action: "write" | "forget";
|
|
528
550
|
scope: MemoryScopeNameV1;
|
|
529
551
|
projectId: string;
|
|
530
|
-
|
|
552
|
+
/**
|
|
553
|
+
* `pending` when the intent cannot name a tier yet. A forget may rewrite
|
|
554
|
+
* the profile file, one or more log files, or write a retraction, and
|
|
555
|
+
* which it is, is not known until it has run; the `memory/written` events
|
|
556
|
+
* that follow name the real tier and path.
|
|
557
|
+
*/
|
|
558
|
+
tier: "profile" | "log" | "note" | "pending";
|
|
559
|
+
/** Empty when the intent cannot name a path yet, for the same reason. */
|
|
531
560
|
path: string;
|
|
532
561
|
contentHash: string;
|
|
533
562
|
};
|
|
@@ -780,6 +809,17 @@ function memoryTier(value: unknown, label: string): void {
|
|
|
780
809
|
}
|
|
781
810
|
}
|
|
782
811
|
|
|
812
|
+
/**
|
|
813
|
+
* The tier an *intent* names. A forget does not know which files it will
|
|
814
|
+
* touch until it has run — it may rewrite the profile file, one or more log
|
|
815
|
+
* files, or write a retraction — so `pending` is the honest answer, and the
|
|
816
|
+
* `memory/written` events that follow name the real tier and path.
|
|
817
|
+
*/
|
|
818
|
+
function memoryIntentTier(value: unknown, label: string): void {
|
|
819
|
+
if (value === "pending") return;
|
|
820
|
+
memoryTier(value, label);
|
|
821
|
+
}
|
|
822
|
+
|
|
783
823
|
function memoryAction(value: unknown, label: string): void {
|
|
784
824
|
if (value !== "write" && value !== "forget") {
|
|
785
825
|
throw new Error(`${label} is invalid`);
|
|
@@ -1327,6 +1367,36 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
|
1327
1367
|
eventString(event.contentHash, "session event.contentHash");
|
|
1328
1368
|
eventString(event.generationId, "session event.generationId");
|
|
1329
1369
|
break;
|
|
1370
|
+
case "package/effect-failed":
|
|
1371
|
+
requireEventKeys(
|
|
1372
|
+
event,
|
|
1373
|
+
keys(
|
|
1374
|
+
"turn",
|
|
1375
|
+
"step",
|
|
1376
|
+
"effectId",
|
|
1377
|
+
"effect",
|
|
1378
|
+
"reason",
|
|
1379
|
+
...(Object.hasOwn(event, "failureId") ? ["failureId"] : []),
|
|
1380
|
+
),
|
|
1381
|
+
"session event",
|
|
1382
|
+
);
|
|
1383
|
+
turn();
|
|
1384
|
+
step();
|
|
1385
|
+
eventString(event.effectId, "session event.effectId");
|
|
1386
|
+
if (
|
|
1387
|
+
event.effect !== "author" &&
|
|
1388
|
+
event.effect !== "undo" &&
|
|
1389
|
+
event.effect !== "catalog-change"
|
|
1390
|
+
) {
|
|
1391
|
+
throw new Error(
|
|
1392
|
+
'session event.effect must be "author", "undo" or "catalog-change"',
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
eventString(event.reason, "session event.reason", true);
|
|
1396
|
+
if (Object.hasOwn(event, "failureId")) {
|
|
1397
|
+
eventString(event.failureId, "session event.failureId");
|
|
1398
|
+
}
|
|
1399
|
+
break;
|
|
1330
1400
|
case "package/hook-failed":
|
|
1331
1401
|
requireEventKeys(
|
|
1332
1402
|
event,
|
|
@@ -1573,9 +1643,9 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
|
1573
1643
|
);
|
|
1574
1644
|
memoryScope(entry.scope, `${label}.scope`);
|
|
1575
1645
|
eventString(entry.projectId, `${label}.projectId`, true);
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
}
|
|
1646
|
+
// `note` too: a note lives in the log file, and recording it as `log`
|
|
1647
|
+
// left a reader of the durable event unable to tell the tiers apart.
|
|
1648
|
+
memoryTier(entry.tier, `${label}.tier`);
|
|
1579
1649
|
eventString(entry.via, `${label}.via`, true);
|
|
1580
1650
|
eventString(entry.learnedAt, `${label}.learnedAt`);
|
|
1581
1651
|
eventString(entry.text, `${label}.text`);
|
|
@@ -1630,8 +1700,8 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
|
|
|
1630
1700
|
memoryAction(event.action, "session event.action");
|
|
1631
1701
|
memoryScope(event.scope, "session event.scope");
|
|
1632
1702
|
eventString(event.projectId, "session event.projectId", true);
|
|
1633
|
-
|
|
1634
|
-
eventString(event.path, "session event.path");
|
|
1703
|
+
memoryIntentTier(event.tier, "session event.tier");
|
|
1704
|
+
eventString(event.path, "session event.path", true);
|
|
1635
1705
|
eventString(event.contentHash, "session event.contentHash");
|
|
1636
1706
|
break;
|
|
1637
1707
|
case "memory/written":
|
package/src/workspace.ts
CHANGED
|
@@ -313,7 +313,18 @@ export function isWorkspaceConflictV1(
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
export type WorkspaceWriteOutcomeV1 =
|
|
316
|
-
| {
|
|
316
|
+
| {
|
|
317
|
+
status: "ok";
|
|
318
|
+
generation: WorkspaceGenerationV1;
|
|
319
|
+
/**
|
|
320
|
+
* The bytes are durable but the generation ledger has not recorded them
|
|
321
|
+
* yet. A write is still `ok`: the fact is the bytes, and the ledger is
|
|
322
|
+
* the index of the fact. The generation travels beside the bytes, so
|
|
323
|
+
* `reconcile` repairs the entry on its own; a caller that wants to say
|
|
324
|
+
* so may, and a caller that does not may ignore it.
|
|
325
|
+
*/
|
|
326
|
+
ledgerPending?: true;
|
|
327
|
+
}
|
|
317
328
|
| WorkspaceConflictV1
|
|
318
329
|
| WorkspaceFailureV1;
|
|
319
330
|
|