@frockbot/kernel-contracts 0.0.0 → 0.1.1

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/src/session.ts ADDED
@@ -0,0 +1,521 @@
1
+ import { type Context, Service } from "cordis";
2
+ import type {
3
+ LlmMessage,
4
+ SessionEvent,
5
+ SessionEventEnvelope,
6
+ SessionEventInput,
7
+ } from "./types.js";
8
+ import { toolCallOccurrences, toolIntentMatches } from "./types.js";
9
+
10
+ export interface ToolOccurrenceJournalEntry {
11
+ occurrence: ReturnType<typeof toolCallOccurrences>[number];
12
+ intent?: Extract<SessionEvent, { type: "tool/call" }>;
13
+ result?: Extract<SessionEvent, { type: "tool/result" }>;
14
+ }
15
+
16
+ export function validateToolOccurrenceJournal(
17
+ events: readonly SessionEvent[],
18
+ ): ReadonlyMap<string, ToolOccurrenceJournalEntry> {
19
+ const journal = new Map<string, ToolOccurrenceJournalEntry>();
20
+ const startedTurns = new Set<number>();
21
+ const startedSteps = new Set<string>();
22
+ let openTurn: number | undefined;
23
+ let openStep:
24
+ { turn: number; step: number; occurrences: Set<string> } | undefined;
25
+ for (const event of events) {
26
+ if (event.type === "turn/start") {
27
+ if (openTurn !== undefined) {
28
+ throw new Error(
29
+ `turn ${event.turn} started while turn ${openTurn} is open`,
30
+ );
31
+ }
32
+ if (startedTurns.has(event.turn)) {
33
+ throw new Error(`turn ${event.turn} started more than once`);
34
+ }
35
+ startedTurns.add(event.turn);
36
+ openTurn = event.turn;
37
+ continue;
38
+ }
39
+ if (event.type === "turn/end") {
40
+ if (openTurn !== event.turn) {
41
+ throw new Error(`turn ${event.turn} ended without its matching start`);
42
+ }
43
+ if (openStep) {
44
+ throw new Error(
45
+ `turn ${event.turn} ended while step ${openStep.step} is open`,
46
+ );
47
+ }
48
+ openTurn = undefined;
49
+ continue;
50
+ }
51
+ if (event.type === "step/start") {
52
+ if (openTurn !== event.turn) {
53
+ throw new Error(
54
+ `step ${event.turn}:${event.step} started outside its open turn`,
55
+ );
56
+ }
57
+ if (openStep) {
58
+ throw new Error(
59
+ `step ${event.turn}:${event.step} started while step ${openStep.turn}:${openStep.step} is open`,
60
+ );
61
+ }
62
+ const key = `${event.turn}:${event.step}`;
63
+ if (startedSteps.has(key)) {
64
+ throw new Error(`step ${key} started more than once`);
65
+ }
66
+ startedSteps.add(key);
67
+ openStep = {
68
+ turn: event.turn,
69
+ step: event.step,
70
+ occurrences: new Set(),
71
+ };
72
+ continue;
73
+ }
74
+ if (event.type === "step/end") {
75
+ if (
76
+ !openStep ||
77
+ openStep.turn !== event.turn ||
78
+ openStep.step !== event.step
79
+ ) {
80
+ throw new Error(
81
+ `step ${event.turn}:${event.step} ended without its matching start`,
82
+ );
83
+ }
84
+ const unsettled = [...openStep.occurrences]
85
+ .map((occurrenceId) => journal.get(occurrenceId)!)
86
+ .find((entry) => !entry.intent || !entry.result);
87
+ if (unsettled) {
88
+ throw new Error(
89
+ `tool occurrence "${unsettled.occurrence.occurrenceId}" was not settled before step end`,
90
+ );
91
+ }
92
+ openStep = undefined;
93
+ continue;
94
+ }
95
+ if (event.type === "assistant/message") {
96
+ if (event.toolCalls.length === 0) continue;
97
+ if (
98
+ !openStep ||
99
+ openStep.turn !== event.turn ||
100
+ openStep.step !== event.step
101
+ ) {
102
+ throw new Error(
103
+ `assistant tool calls for ${event.turn}:${event.step} are outside their open step`,
104
+ );
105
+ }
106
+ for (const occurrence of toolCallOccurrences(
107
+ event.turn,
108
+ event.step,
109
+ event.toolCalls,
110
+ )) {
111
+ if (journal.has(occurrence.occurrenceId)) {
112
+ throw new Error(
113
+ `tool occurrence "${occurrence.occurrenceId}" has multiple assistant calls`,
114
+ );
115
+ }
116
+ journal.set(occurrence.occurrenceId, { occurrence });
117
+ openStep.occurrences.add(occurrence.occurrenceId);
118
+ }
119
+ continue;
120
+ }
121
+ if (event.type !== "tool/call" && event.type !== "tool/result") continue;
122
+ if (
123
+ !openStep ||
124
+ openStep.turn !== event.turn ||
125
+ openStep.step !== event.step
126
+ ) {
127
+ throw new Error(
128
+ `tool occurrence "${event.occurrenceId}" is outside its open step`,
129
+ );
130
+ }
131
+ const entry = journal.get(event.occurrenceId);
132
+ if (
133
+ !entry ||
134
+ entry.occurrence.turn !== event.turn ||
135
+ entry.occurrence.step !== event.step ||
136
+ entry.occurrence.call.name !== event.name
137
+ ) {
138
+ throw new Error(
139
+ `tool occurrence "${event.occurrenceId}" does not match an assistant call`,
140
+ );
141
+ }
142
+ if (event.type === "tool/call") {
143
+ if (!toolIntentMatches(entry.occurrence.call, event)) {
144
+ throw new Error(
145
+ `tool occurrence "${event.occurrenceId}" input does not match its assistant call`,
146
+ );
147
+ }
148
+ if (entry.intent || entry.result) {
149
+ throw new Error(
150
+ `tool occurrence "${event.occurrenceId}" has duplicate intent`,
151
+ );
152
+ }
153
+ entry.intent = event;
154
+ continue;
155
+ }
156
+ if (!entry.intent) {
157
+ throw new Error(
158
+ `tool occurrence "${event.occurrenceId}" has a result without intent`,
159
+ );
160
+ }
161
+ if (entry.result) {
162
+ throw new Error(
163
+ `tool occurrence "${event.occurrenceId}" has duplicate results`,
164
+ );
165
+ }
166
+ entry.result = event;
167
+ }
168
+ return journal;
169
+ }
170
+
171
+ export function validateSettledToolOccurrenceJournal(
172
+ events: readonly SessionEvent[],
173
+ ): ReadonlyMap<string, ToolOccurrenceJournalEntry> {
174
+ const journal = validateToolOccurrenceJournal(events);
175
+ const unsettled = [...journal.values()].find(
176
+ (entry) => !entry.intent || !entry.result,
177
+ );
178
+ if (unsettled) {
179
+ throw new Error(
180
+ `tool occurrence "${unsettled.occurrence.occurrenceId}" is not durably settled`,
181
+ );
182
+ }
183
+ return journal;
184
+ }
185
+
186
+ declare module "cordis" {
187
+ interface Context {
188
+ sessions: SessionStore;
189
+ }
190
+
191
+ interface Events {
192
+ "session/event": (envelope: SessionEventEnvelope) => void;
193
+ }
194
+ }
195
+
196
+ export type PersistSessionEvents = (
197
+ sessionId: string,
198
+ events: readonly SessionEvent[],
199
+ ) => Promise<void>;
200
+
201
+ /** Most resolved attachments one resident Session holds. */
202
+ export const SESSION_ATTACHMENT_CACHE_LIMIT = 4;
203
+ /** Largest resolved attachment a Session holds, in base64 characters. */
204
+ export const SESSION_ATTACHMENT_MAX_BASE64 = 8_000_000;
205
+
206
+ export class Session {
207
+ readonly id: string;
208
+ #events: SessionEvent[] = [];
209
+ #disposed = false;
210
+ #emit: (envelope: SessionEventEnvelope) => void;
211
+ #persist?: PersistSessionEvents;
212
+ #pendingPersistence: Promise<void> = Promise.resolve();
213
+ /**
214
+ * Resolved attachment bytes, keyed by content hash, held only while this
215
+ * Session is resident.
216
+ *
217
+ * The session event log is one Durable Object value and a screenshot in it
218
+ * would be a durable record that grows past what the object can hold, so an
219
+ * attachment records a Workspace path and a content hash and nothing else.
220
+ * A tool that produced the bytes offers them here, and the request derived
221
+ * while they are still held carries them to a model that can see images. On
222
+ * the far side of an eviction the reference stands alone: the adapter says
223
+ * where the image is rather than showing it, which is the observable
224
+ * outcome, not a silent one.
225
+ */
226
+ #attachmentBytes = new Map<string, string>();
227
+
228
+ constructor(
229
+ id: string,
230
+ emit: (envelope: SessionEventEnvelope) => void,
231
+ initialEvents: readonly SessionEvent[] = [],
232
+ persist?: PersistSessionEvents,
233
+ ) {
234
+ this.id = id;
235
+ this.#emit = emit;
236
+ this.#persist = persist;
237
+ if (initialEvents.length > 0) {
238
+ for (const [index, event] of initialEvents.entries()) {
239
+ if (event.seq !== index) {
240
+ throw new Error(`session "${id}" has a non-contiguous event log`);
241
+ }
242
+ }
243
+ this.#events = structuredClone([...initialEvents]);
244
+ } else {
245
+ this.append({
246
+ type: "session/created",
247
+ createdAt: new Date().toISOString(),
248
+ });
249
+ }
250
+ }
251
+
252
+ get events(): readonly SessionEvent[] {
253
+ return this.#events;
254
+ }
255
+
256
+ get disposed(): boolean {
257
+ return this.#disposed;
258
+ }
259
+
260
+ append(input: SessionEventInput): SessionEvent {
261
+ return this.appendBatch([input])[0];
262
+ }
263
+
264
+ appendBatch(inputs: SessionEventInput[]): SessionEvent[] {
265
+ if (this.#disposed) throw new Error(`session "${this.id}" is disposed`);
266
+ const timestamp = new Date().toISOString();
267
+ const events = inputs.map((input, index) => ({
268
+ ...input,
269
+ seq: this.#events.length + index,
270
+ timestamp,
271
+ })) as SessionEvent[];
272
+ this.#events.push(...events);
273
+ for (const event of events) this.#emit({ sessionId: this.id, event });
274
+ if (this.#persist && events.length > 0) {
275
+ const durableEvents = structuredClone(events);
276
+ this.#pendingPersistence = this.#pendingPersistence.then(() =>
277
+ this.#persist?.(this.id, durableEvents),
278
+ );
279
+ }
280
+ return events;
281
+ }
282
+
283
+ flush(): Promise<void> {
284
+ return this.#pendingPersistence;
285
+ }
286
+
287
+ /**
288
+ * Offers the bytes of one attachment for as long as this Session is
289
+ * resident. Bounded by count and by size: a cache that could grow with the
290
+ * conversation would be durable state wearing a different hat.
291
+ */
292
+ offerAttachmentBytes(contentHash: string, dataBase64: string): void {
293
+ if (!/^[0-9a-f]{64}$/.test(contentHash)) return;
294
+ if (dataBase64.length > SESSION_ATTACHMENT_MAX_BASE64) return;
295
+ this.#attachmentBytes.delete(contentHash);
296
+ this.#attachmentBytes.set(contentHash, dataBase64);
297
+ while (this.#attachmentBytes.size > SESSION_ATTACHMENT_CACHE_LIMIT) {
298
+ const oldest = this.#attachmentBytes.keys().next().value;
299
+ if (oldest === undefined) break;
300
+ this.#attachmentBytes.delete(oldest);
301
+ }
302
+ }
303
+
304
+ deriveMessages(): LlmMessage[] {
305
+ const messages: LlmMessage[] = [];
306
+ const journal = validateToolOccurrenceJournal(this.#events);
307
+ for (const event of this.#events) {
308
+ if (event.type === "user/message") {
309
+ messages.push({ role: "user", content: event.text });
310
+ } else if (event.type === "assistant/message") {
311
+ messages.push({
312
+ role: "assistant",
313
+ content: event.text,
314
+ toolCalls: event.toolCalls,
315
+ });
316
+ } else if (event.type === "tool/result") {
317
+ const call = journal.get(event.occurrenceId)!.occurrence.call;
318
+ messages.push({
319
+ role: "tool",
320
+ callId: call.id,
321
+ name: event.name,
322
+ content: event.content,
323
+ isError: event.isError,
324
+ ...(event.attachments && event.attachments.length > 0
325
+ ? {
326
+ attachments: event.attachments.map((attachment) => {
327
+ const resolved = this.#attachmentBytes.get(
328
+ attachment.contentHash,
329
+ );
330
+ return resolved === undefined
331
+ ? attachment
332
+ : { ...attachment, dataBase64: resolved };
333
+ }),
334
+ }
335
+ : {}),
336
+ });
337
+ }
338
+ }
339
+ return messages;
340
+ }
341
+
342
+ nextTurn(): number {
343
+ let latest = 0;
344
+ for (const event of this.#events) {
345
+ if (event.type === "turn/start") latest = Math.max(latest, event.turn);
346
+ }
347
+ return latest + 1;
348
+ }
349
+
350
+ reconcileInterrupted(): SessionEvent[] {
351
+ const repairs = this.interruptionRepairs(true);
352
+ return repairs.length > 0 ? this.appendBatch(repairs) : [];
353
+ }
354
+
355
+ reconcileForResume(): SessionEvent[] {
356
+ const repairs = this.interruptionRepairs(false);
357
+ return repairs.length > 0 ? this.appendBatch(repairs) : [];
358
+ }
359
+
360
+ private interruptionRepairs(closeTurn: boolean): SessionEventInput[] {
361
+ let openTurn: number | undefined;
362
+ let openStep: { turn: number; step: number } | undefined;
363
+ let openStepHasAssistant = false;
364
+ const unresolvedModelRequests = new Set<string>();
365
+ const journal = validateToolOccurrenceJournal(this.#events);
366
+
367
+ for (const event of this.#events) {
368
+ if (event.type === "turn/start") openTurn = event.turn;
369
+ if (event.type === "turn/end" && openTurn === event.turn)
370
+ openTurn = undefined;
371
+ if (event.type === "step/start") {
372
+ openStep = { turn: event.turn, step: event.step };
373
+ openStepHasAssistant = false;
374
+ }
375
+ if (
376
+ event.type === "step/end" &&
377
+ openStep?.turn === event.turn &&
378
+ openStep.step === event.step
379
+ ) {
380
+ openStep = undefined;
381
+ }
382
+ if (event.type === "model/request") {
383
+ unresolvedModelRequests.add(event.request.requestId);
384
+ }
385
+ if (event.type === "model/effect-not-started") {
386
+ unresolvedModelRequests.delete(event.requestId);
387
+ }
388
+ if (event.type === "assistant/message") {
389
+ unresolvedModelRequests.delete(event.requestId);
390
+ if (openStep?.turn === event.turn && openStep.step === event.step) {
391
+ openStepHasAssistant = true;
392
+ }
393
+ }
394
+ }
395
+
396
+ const repairs: SessionEventInput[] = [];
397
+ if (closeTurn) {
398
+ for (const entry of journal.values()) {
399
+ if (!entry.intent || entry.result) continue;
400
+ const event = entry.intent;
401
+ repairs.push({
402
+ type: "tool/result",
403
+ turn: event.turn,
404
+ step: event.step,
405
+ occurrenceId: event.occurrenceId,
406
+ name: event.name,
407
+ content: "Interrupted before a durable result was recorded.",
408
+ isError: true,
409
+ status: "interrupted",
410
+ });
411
+ }
412
+ }
413
+ if (
414
+ openStep &&
415
+ unresolvedModelRequests.size === 0 &&
416
+ (closeTurn || !openStepHasAssistant)
417
+ ) {
418
+ repairs.push({ type: "step/end", ...openStep, outcome: "interrupted" });
419
+ }
420
+ if (closeTurn && openTurn !== undefined) {
421
+ repairs.push({
422
+ type: "turn/end",
423
+ turn: openTurn,
424
+ outcome: "interrupted",
425
+ });
426
+ }
427
+ return repairs;
428
+ }
429
+
430
+ dispose(): void {
431
+ if (this.#disposed) return;
432
+ this.append({
433
+ type: "session/disposed",
434
+ disposedAt: new Date().toISOString(),
435
+ });
436
+ this.#disposed = true;
437
+ }
438
+ }
439
+
440
+ export interface SessionStoreConfig {
441
+ initialSessions?: Readonly<Record<string, readonly SessionEvent[]>>;
442
+ persistEvents?: PersistSessionEvents;
443
+ }
444
+
445
+ export class SessionStore extends Service {
446
+ private sessions = new Map<string, Session>();
447
+ private initialSessions: Readonly<Record<string, readonly SessionEvent[]>>;
448
+ private persistEvents?: PersistSessionEvents;
449
+ private preparedSessions = new Map<
450
+ string,
451
+ {
452
+ initialEvents?: readonly SessionEvent[];
453
+ persistEvents?: PersistSessionEvents;
454
+ }
455
+ >();
456
+
457
+ constructor(ctx: Context, config: SessionStoreConfig = {}) {
458
+ super(ctx, "sessions");
459
+ this.initialSessions = config.initialSessions ?? {};
460
+ this.persistEvents = config.persistEvents;
461
+ }
462
+
463
+ prepare(
464
+ sessionId: string,
465
+ options: {
466
+ initialEvents?: readonly SessionEvent[];
467
+ persistEvents?: PersistSessionEvents;
468
+ },
469
+ ): () => void {
470
+ if (this.sessions.has(sessionId) || this.preparedSessions.has(sessionId)) {
471
+ throw new Error(`session "${sessionId}" already exists`);
472
+ }
473
+ this.preparedSessions.set(sessionId, options);
474
+ return () => {
475
+ if (this.preparedSessions.get(sessionId) === options) {
476
+ this.preparedSessions.delete(sessionId);
477
+ }
478
+ };
479
+ }
480
+
481
+ create(sessionId: string): Session {
482
+ if (this.sessions.has(sessionId)) {
483
+ throw new Error(`session "${sessionId}" already exists`);
484
+ }
485
+ const prepared = this.preparedSessions.get(sessionId);
486
+ this.preparedSessions.delete(sessionId);
487
+ const session = new Session(
488
+ sessionId,
489
+ (envelope) => {
490
+ this.ctx.emit("session/event", envelope);
491
+ },
492
+ prepared?.initialEvents ?? this.initialSessions[sessionId],
493
+ prepared?.persistEvents ?? this.persistEvents,
494
+ );
495
+ this.sessions.set(sessionId, session);
496
+ return session;
497
+ }
498
+
499
+ get(sessionId: string): Session | undefined {
500
+ return this.sessions.get(sessionId);
501
+ }
502
+
503
+ list(): Session[] {
504
+ return [...this.sessions.values()];
505
+ }
506
+
507
+ disposeSession(sessionId: string): void {
508
+ const session = this.sessions.get(sessionId);
509
+ if (!session) return;
510
+ session.dispose();
511
+ this.sessions.delete(sessionId);
512
+ }
513
+
514
+ [Service.init](): () => void {
515
+ return () => {
516
+ for (const session of this.sessions.values()) session.dispose();
517
+ this.sessions.clear();
518
+ this.preparedSessions.clear();
519
+ };
520
+ }
521
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeSkillRefV1,
4
+ decodeSkillRefsV1,
5
+ formatSkillRefV1,
6
+ MAX_INVOKED_SKILLS_V1,
7
+ parseSkillRefV1,
8
+ } from "./skills.js";
9
+
10
+ describe("a Skill ref crossing a seam", () => {
11
+ test("round-trips its canonical string form for every source", () => {
12
+ const refs = [
13
+ { schemaVersion: 1 as const, source: "bot" as const, slug: "standup" },
14
+ { schemaVersion: 1 as const, source: "user" as const, slug: "standup" },
15
+ { schemaVersion: 1 as const, source: "managed" as const, slug: "teach" },
16
+ {
17
+ schemaVersion: 1 as const,
18
+ source: "plugin" as const,
19
+ slug: "compose",
20
+ packageId: "composio",
21
+ },
22
+ ];
23
+ expect(refs.map(formatSkillRefV1)).toEqual([
24
+ "bot/standup",
25
+ "user/standup",
26
+ "managed/teach",
27
+ "plugin/composio/compose",
28
+ ]);
29
+ for (const ref of refs) {
30
+ expect(parseSkillRefV1(formatSkillRefV1(ref))).toEqual(ref);
31
+ }
32
+ });
33
+
34
+ test("admits every declared source, so K1 and K2 add no wire change", () => {
35
+ for (const source of ["bot", "user", "managed", "plugin"] as const) {
36
+ const ref = decodeSkillRefV1({
37
+ schemaVersion: 1,
38
+ source,
39
+ slug: "standup",
40
+ ...(source === "plugin" ? { packageId: "composio" } : {}),
41
+ });
42
+ expect(ref.source).toBe(source);
43
+ }
44
+ });
45
+
46
+ test("refuses an unknown source", () => {
47
+ expect(() =>
48
+ decodeSkillRefV1({ schemaVersion: 1, source: "workflow", slug: "s" }),
49
+ ).toThrow(/source is invalid/u);
50
+ });
51
+
52
+ test("refuses an unknown field rather than carrying it into durable state", () => {
53
+ expect(() =>
54
+ decodeSkillRefV1({
55
+ schemaVersion: 1,
56
+ source: "bot",
57
+ slug: "standup",
58
+ body: "do the thing",
59
+ }),
60
+ ).toThrow(/unknown fields/u);
61
+ });
62
+
63
+ test("refuses a packageId on a source that has no Package", () => {
64
+ expect(() =>
65
+ decodeSkillRefV1({
66
+ schemaVersion: 1,
67
+ source: "bot",
68
+ slug: "standup",
69
+ packageId: "composio",
70
+ }),
71
+ ).toThrow(/only valid on a plugin Skill/u);
72
+ });
73
+
74
+ test("requires a packageId on a plugin Skill", () => {
75
+ expect(() =>
76
+ decodeSkillRefV1({ schemaVersion: 1, source: "plugin", slug: "compose" }),
77
+ ).toThrow(/packageId is invalid/u);
78
+ });
79
+
80
+ test("refuses a malformed slug and a wrong schema version", () => {
81
+ expect(() =>
82
+ decodeSkillRefV1({ schemaVersion: 1, source: "bot", slug: "Standup" }),
83
+ ).toThrow(/slug is invalid/u);
84
+ expect(() =>
85
+ decodeSkillRefV1({ schemaVersion: 2, source: "bot", slug: "standup" }),
86
+ ).toThrow(/schemaVersion is invalid/u);
87
+ });
88
+
89
+ test("reads no ref out of a string that is not one", () => {
90
+ for (const value of ["bot", "bot/", "/standup", "plugin/standup", 7]) {
91
+ expect(parseSkillRefV1(value)).toBeUndefined();
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("the list one Turn invokes", () => {
97
+ const ref = (slug: string) => ({
98
+ schemaVersion: 1 as const,
99
+ source: "bot" as const,
100
+ slug,
101
+ });
102
+
103
+ test("admits up to the bound", () => {
104
+ const refs = ["a", "b", "c"].map(ref);
105
+ expect(decodeSkillRefsV1(refs)).toHaveLength(MAX_INVOKED_SKILLS_V1);
106
+ });
107
+
108
+ test("refuses more than the bound", () => {
109
+ expect(() => decodeSkillRefsV1(["a", "b", "c", "d"].map(ref))).toThrow(
110
+ /at most 3 Skills/u,
111
+ );
112
+ });
113
+
114
+ test("refuses the same Skill twice", () => {
115
+ expect(() => decodeSkillRefsV1([ref("a"), ref("a")])).toThrow(
116
+ /more than once/u,
117
+ );
118
+ });
119
+
120
+ test("refuses a value that is not an array", () => {
121
+ expect(() => decodeSkillRefsV1(ref("a"))).toThrow(/must be an array/u);
122
+ });
123
+ });