@frockbot/kernel-contracts 0.3.11 → 0.3.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-contracts",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./send-to-user.js";
12
12
  export * from "./session.js";
13
13
  export * from "./skills.js";
14
14
  export * from "./tool-execution.js";
15
+ export * from "./turn-deadline.js";
15
16
  export * from "./turn-history.js";
16
17
  export * from "./types.js";
17
18
  export * from "./workspace.js";
@@ -55,6 +55,54 @@ test("a Bot-isolate hook failure is an exact durable session event", () => {
55
55
  );
56
56
  });
57
57
 
58
+ test("a compaction is an exact durable session event", () => {
59
+ const intent = {
60
+ type: "conversation/compaction-intent",
61
+ effectId: "compaction-1",
62
+ throughTurn: 6,
63
+ provider: "ollama-cloud",
64
+ model: "kimi-k2",
65
+ seq: 0,
66
+ timestamp,
67
+ } as const;
68
+ expect(decodeSessionEvent(intent)).toEqual(intent);
69
+ expect(() => decodeSessionEvent({ ...intent, turn: 6 })).toThrow(
70
+ /invalid fields/,
71
+ );
72
+ const compacted = {
73
+ type: "conversation/compacted",
74
+ effectId: "compaction-1",
75
+ fromTurn: 1,
76
+ throughTurn: 6,
77
+ summary: "## Summary\nThey shipped it.",
78
+ identifiers: ["applet-9f2c"],
79
+ provider: "ollama-cloud",
80
+ model: "kimi-k2",
81
+ seq: 1,
82
+ timestamp,
83
+ } satisfies SessionEvent;
84
+ expect(decodeSessionEvent(structuredClone(compacted))).toEqual(compacted);
85
+ // A range that ends before it starts is not a range.
86
+ expect(() => decodeSessionEvent({ ...compacted, fromTurn: 9 })).toThrow(
87
+ /throughTurn is invalid/,
88
+ );
89
+ expect(() =>
90
+ decodeSessionEvent({ ...compacted, summary: "x".repeat(20_000) }),
91
+ ).toThrow(/summary is too long/);
92
+ expect(() =>
93
+ decodeSessionEvent({ ...compacted, identifiers: "applet-9f2c" }),
94
+ ).toThrow(/bounded array/);
95
+ const failed = {
96
+ type: "conversation/compaction-failed",
97
+ effectId: "compaction-1",
98
+ throughTurn: 6,
99
+ reason: "the summariser ran past its deadline",
100
+ seq: 2,
101
+ timestamp,
102
+ } as const;
103
+ expect(decodeSessionEvent(failed)).toEqual(failed);
104
+ });
105
+
58
106
  describe("SessionStore", () => {
59
107
  test("accepts resumable tool crash states only while their step is open", () => {
60
108
  const assistant = [
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The longest a single Turn may run before the loop stops waiting for it.
3
+ *
4
+ * Nothing bounded a Turn's wall clock before this: one hung for seventeen
5
+ * minutes with an animated avatar and nothing else, and would have hung until
6
+ * the isolate died. Fifteen minutes is well past any Turn a person is watching
7
+ * and well inside the point at which they have concluded the product is broken.
8
+ *
9
+ * It lives in the contracts rather than in the loop that enforces it because
10
+ * two parties need the same number: the loop, which aborts on it, and every
11
+ * reader that has to decide whether a run still marked `running` can possibly
12
+ * still be running. A record older than this deadline is not working — whatever
13
+ * its status field says — and a reader that used a different number would
14
+ * either leave a dead Turn wearing the activity ring or cut a live one off.
15
+ */
16
+ export const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
package/src/types.ts CHANGED
@@ -155,6 +155,18 @@ export type TurnOutcome = StepOutcome;
155
155
  /** Longest `reason` a `turn/end` event may carry. */
156
156
  export const TURN_END_REASON_MAX_LENGTH = 500;
157
157
 
158
+ /**
159
+ * Bounds on one `conversation/compacted` event (ADR 0030).
160
+ *
161
+ * A summary is model output that is stored durably and replayed into every
162
+ * later request of the conversation, so it is bounded here rather than trusted
163
+ * to be short: the whole point of compaction is that the window stops growing.
164
+ */
165
+ export const COMPACTION_SUMMARY_MAX_LENGTH = 12_000;
166
+ export const COMPACTION_IDENTIFIERS_MAX = 200;
167
+ export const COMPACTION_IDENTIFIER_MAX_LENGTH = 400;
168
+ export const COMPACTION_FAILURE_REASON_MAX_LENGTH = 500;
169
+
158
170
  /**
159
171
  * Truncates a failure description to what a `turn/end` `reason` accepts.
160
172
  * Returns `undefined` when nothing describable remains.
@@ -755,6 +767,55 @@ export interface SessionEventMap {
755
767
  taskId: string;
756
768
  requestedBy: "bot" | "user";
757
769
  };
770
+ /**
771
+ * The durable intent to compact this conversation, recorded before the
772
+ * summariser model call it fences (ADR 0030). It carries no `turn`: a
773
+ * compaction is evaluated *after* a Turn has ended, so it belongs to the
774
+ * conversation rather than to any step of it — the `bot/renamed` shape.
775
+ *
776
+ * `throughTurn` is the last Turn the attempt covers. A compaction always
777
+ * covers a prefix, so the range is that one number, and it is also the
778
+ * idempotency key: an intent is refused when a `conversation/compacted`
779
+ * already covers it.
780
+ */
781
+ "conversation/compaction-intent": {
782
+ effectId: string;
783
+ throughTurn: number;
784
+ provider: string;
785
+ model: string;
786
+ };
787
+ /**
788
+ * One summary of Turns `fromTurn` through `throughTurn`, computed once and
789
+ * replayed on every later request. The newest such event supersedes every
790
+ * earlier one, because each covers a prefix and the summariser folds the
791
+ * previous summary into the range it extends.
792
+ *
793
+ * `identifiers` is the summariser's own "Identifiers mentioned" list, kept
794
+ * as a field rather than left inside the prose: an opaque id that a summary
795
+ * paraphrases produces a later tool call with a plausible-looking wrong
796
+ * argument, so what the model was asked to preserve is what the log records.
797
+ */
798
+ "conversation/compacted": {
799
+ effectId: string;
800
+ fromTurn: number;
801
+ throughTurn: number;
802
+ summary: string;
803
+ identifiers: string[];
804
+ provider: string;
805
+ model: string;
806
+ };
807
+ /**
808
+ * A compaction intent that produced no summary — the summariser failed, ran
809
+ * past its deadline, answered unusably, or a restart interrupted it. Never
810
+ * fatal: the conversation carries on under the whole-Turn eviction ADR 0027
811
+ * already applies, and the next attempt is spaced by backoff counted from
812
+ * these events.
813
+ */
814
+ "conversation/compaction-failed": {
815
+ effectId: string;
816
+ throughTurn: number;
817
+ reason: string;
818
+ };
758
819
  "step/end": { turn: number; step: number; outcome: StepOutcome };
759
820
  /**
760
821
  * `reason` states why a Turn ended in a non-`completed` outcome, so the
@@ -2026,6 +2087,82 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
2026
2087
  }
2027
2088
  break;
2028
2089
  }
2090
+ case "conversation/compaction-intent":
2091
+ requireEventKeys(
2092
+ event,
2093
+ keys("effectId", "throughTurn", "provider", "model"),
2094
+ "session event",
2095
+ );
2096
+ eventString(event.effectId, "session event.effectId");
2097
+ eventInteger(event.throughTurn, "session event.throughTurn", 1);
2098
+ eventString(event.provider, "session event.provider");
2099
+ eventString(event.model, "session event.model");
2100
+ break;
2101
+ case "conversation/compacted": {
2102
+ requireEventKeys(
2103
+ event,
2104
+ keys(
2105
+ "effectId",
2106
+ "fromTurn",
2107
+ "throughTurn",
2108
+ "summary",
2109
+ "identifiers",
2110
+ "provider",
2111
+ "model",
2112
+ ),
2113
+ "session event",
2114
+ );
2115
+ eventString(event.effectId, "session event.effectId");
2116
+ const fromTurn = eventInteger(
2117
+ event.fromTurn,
2118
+ "session event.fromTurn",
2119
+ 1,
2120
+ );
2121
+ const throughTurn = eventInteger(
2122
+ event.throughTurn,
2123
+ "session event.throughTurn",
2124
+ 1,
2125
+ );
2126
+ if (throughTurn < fromTurn) {
2127
+ throw new Error("session event.throughTurn is invalid");
2128
+ }
2129
+ const summary = eventString(event.summary, "session event.summary");
2130
+ if (summary.length > COMPACTION_SUMMARY_MAX_LENGTH) {
2131
+ throw new Error("session event.summary is too long");
2132
+ }
2133
+ if (
2134
+ !Array.isArray(event.identifiers) ||
2135
+ event.identifiers.length > COMPACTION_IDENTIFIERS_MAX
2136
+ ) {
2137
+ throw new Error("session event.identifiers must be a bounded array");
2138
+ }
2139
+ event.identifiers.forEach((identifier, index) => {
2140
+ const label = `session event.identifiers[${index}]`;
2141
+ if (
2142
+ eventString(identifier, label).length >
2143
+ COMPACTION_IDENTIFIER_MAX_LENGTH
2144
+ ) {
2145
+ throw new Error(`${label} is too long`);
2146
+ }
2147
+ });
2148
+ eventString(event.provider, "session event.provider");
2149
+ eventString(event.model, "session event.model");
2150
+ break;
2151
+ }
2152
+ case "conversation/compaction-failed": {
2153
+ requireEventKeys(
2154
+ event,
2155
+ keys("effectId", "throughTurn", "reason"),
2156
+ "session event",
2157
+ );
2158
+ eventString(event.effectId, "session event.effectId");
2159
+ eventInteger(event.throughTurn, "session event.throughTurn", 1);
2160
+ const reason = eventString(event.reason, "session event.reason");
2161
+ if (reason.length > COMPACTION_FAILURE_REASON_MAX_LENGTH) {
2162
+ throw new Error("session event.reason is too long");
2163
+ }
2164
+ break;
2165
+ }
2029
2166
  case "step/end":
2030
2167
  requireEventKeys(event, keys("turn", "step", "outcome"), "session event");
2031
2168
  turn();