@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 +1 -1
- package/src/index.ts +1 -0
- package/src/session.test.ts +48 -0
- package/src/turn-deadline.ts +16 -0
- package/src/types.ts +137 -0
package/package.json
CHANGED
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";
|
package/src/session.test.ts
CHANGED
|
@@ -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();
|