@frockbot/kernel-contracts 0.0.0 → 0.1.0
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 +21 -6
- package/src/authoring.test.ts +143 -0
- package/src/authoring.ts +189 -0
- package/src/index.ts +11 -0
- package/src/isolate.test.ts +417 -0
- package/src/isolate.ts +704 -0
- package/src/model-invocation.ts +74 -0
- package/src/prompt-assembly.ts +54 -0
- package/src/send-to-user.test.ts +234 -0
- package/src/send-to-user.ts +384 -0
- package/src/session.test.ts +708 -0
- package/src/session.ts +521 -0
- package/src/skills.test.ts +123 -0
- package/src/skills.ts +164 -0
- package/src/tool-attachments.test.ts +100 -0
- package/src/tool-execution.ts +220 -0
- package/src/turn-history.test.ts +54 -0
- package/src/turn-history.ts +33 -0
- package/src/turn-type.test.ts +101 -0
- package/src/types.ts +1791 -0
- package/src/workspace.test.ts +913 -0
- package/src/workspace.ts +1176 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Importing the augmented module is what merges these declarations into cordis.
|
|
2
|
+
import type {} from "cordis";
|
|
3
|
+
import type { LlmStreamEvent, NormalizedModelRequest } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface DurableModelEffect {
|
|
6
|
+
providerEffectId: string;
|
|
7
|
+
request: NormalizedModelRequest;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class LlmEffectNotStartedError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "LlmEffectNotStartedError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LlmReconciliationOutcome =
|
|
18
|
+
| {
|
|
19
|
+
status: "recovered";
|
|
20
|
+
events: readonly LlmStreamEvent[];
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
status: "unavailable";
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface LlmReconciliationCapability {
|
|
28
|
+
retrieve(
|
|
29
|
+
effect: DurableModelEffect,
|
|
30
|
+
signal: AbortSignal,
|
|
31
|
+
): Promise<LlmReconciliationOutcome>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LlmProvider {
|
|
35
|
+
id: string;
|
|
36
|
+
stream(
|
|
37
|
+
request: NormalizedModelRequest,
|
|
38
|
+
signal: AbortSignal,
|
|
39
|
+
): AsyncIterable<LlmStreamEvent>;
|
|
40
|
+
reconciliation?: LlmReconciliationCapability;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The kernel-declared model invocation interface. Implemented by a Package. */
|
|
44
|
+
export interface ModelInvocation {
|
|
45
|
+
stream(
|
|
46
|
+
request: NormalizedModelRequest,
|
|
47
|
+
signal: AbortSignal,
|
|
48
|
+
): AsyncIterable<LlmStreamEvent>;
|
|
49
|
+
reconcile(
|
|
50
|
+
request: NormalizedModelRequest,
|
|
51
|
+
signal: AbortSignal,
|
|
52
|
+
): Promise<LlmReconciliationOutcome>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Provider Packages register themselves through this surface. */
|
|
56
|
+
export interface ModelProviderRegistration {
|
|
57
|
+
register(provider: LlmProvider): () => void;
|
|
58
|
+
get(providerId: string): LlmProvider | undefined;
|
|
59
|
+
list(): LlmProvider[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare module "cordis" {
|
|
63
|
+
interface Context {
|
|
64
|
+
llm: ModelInvocation & ModelProviderRegistration;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Events {
|
|
68
|
+
"llm/stream": (
|
|
69
|
+
request: NormalizedModelRequest,
|
|
70
|
+
signal: AbortSignal,
|
|
71
|
+
next: () => AsyncIterable<LlmStreamEvent>,
|
|
72
|
+
) => AsyncIterable<LlmStreamEvent>;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Importing the augmented module is what merges these declarations into cordis.
|
|
2
|
+
import type {} from "cordis";
|
|
3
|
+
import type { TurnTypeV1 } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface PromptAssemblyContext {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
model: string;
|
|
9
|
+
/**
|
|
10
|
+
* The turn type this Turn was admitted as, so a section can render what the
|
|
11
|
+
* Turn may actually do. A host with no Turn to speak of assembles as `chat`:
|
|
12
|
+
* that is what {@link DEFAULT_PROMPT_ASSEMBLY_TURN_TYPE_V1} is for, and it
|
|
13
|
+
* is a default rather than an optional field so a section never has to guess
|
|
14
|
+
* what an absent turn type meant.
|
|
15
|
+
*/
|
|
16
|
+
turnType: TurnTypeV1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What a host assembles as when it is not running an admitted Turn. */
|
|
20
|
+
export const DEFAULT_PROMPT_ASSEMBLY_TURN_TYPE_V1: TurnTypeV1 = "chat";
|
|
21
|
+
|
|
22
|
+
export interface PromptSection {
|
|
23
|
+
id: string;
|
|
24
|
+
order?: number;
|
|
25
|
+
render(context: PromptAssemblyContext): string | Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PromptAssembly {
|
|
29
|
+
text: string;
|
|
30
|
+
sections: Array<{ id: string; text: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The kernel-declared system prompt interface. Implemented by a Package. */
|
|
34
|
+
export interface PromptAssemblyService {
|
|
35
|
+
assemble(context: PromptAssemblyContext): Promise<PromptAssembly>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Contributing Packages register prompt sections through this surface. */
|
|
39
|
+
export interface PromptSectionRegistration {
|
|
40
|
+
register(section: PromptSection): () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare module "cordis" {
|
|
44
|
+
interface Context {
|
|
45
|
+
systemPrompt: PromptAssemblyService & PromptSectionRegistration;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Events {
|
|
49
|
+
"system-prompt/assemble": (
|
|
50
|
+
context: PromptAssemblyContext,
|
|
51
|
+
next: () => Promise<PromptAssembly>,
|
|
52
|
+
) => Promise<PromptAssembly>;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The send payload codec, and the two durable events that carry it.
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
decodeSendToUserPayloadV1,
|
|
5
|
+
decodeSessionEvent,
|
|
6
|
+
SEND_TO_USER_LIMITS_V1,
|
|
7
|
+
type SendToUserPayloadV1,
|
|
8
|
+
} from "./index.js";
|
|
9
|
+
|
|
10
|
+
const AT = "2026-08-31T10:00:00.000Z";
|
|
11
|
+
|
|
12
|
+
function event(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
13
|
+
return { seq: 3, timestamp: AT, ...overrides };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("the send payload codec", () => {
|
|
17
|
+
test("round-trips every declared payload type", () => {
|
|
18
|
+
const payloads: SendToUserPayloadV1[] = [
|
|
19
|
+
{ type: "text", text: "Booked." },
|
|
20
|
+
{
|
|
21
|
+
type: "attachment",
|
|
22
|
+
url: "https://files.example/receipt.pdf",
|
|
23
|
+
name: "receipt.pdf",
|
|
24
|
+
mediaType: "application/pdf",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "widget",
|
|
28
|
+
widget: {
|
|
29
|
+
prompt: "Which one?",
|
|
30
|
+
helpText: "Either is fine.",
|
|
31
|
+
options: ["Tuesday", "Thursday"],
|
|
32
|
+
allowCustom: true,
|
|
33
|
+
dismissOnMoveOn: false,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{ type: "secret-request", prompt: "Your API key", secretName: "api_key" },
|
|
37
|
+
{ type: "agent-card", agentId: "bot-2", title: "School", body: "Term 3" },
|
|
38
|
+
{
|
|
39
|
+
type: "approval",
|
|
40
|
+
approvalId: "ap-1",
|
|
41
|
+
action: "Delete the staging database",
|
|
42
|
+
rationale: "It has been idle for a month.",
|
|
43
|
+
risk: "high",
|
|
44
|
+
expiresInSeconds: 3_600,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
for (const payload of payloads) {
|
|
49
|
+
expect(decodeSendToUserPayloadV1(payload)).toEqual(payload);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("keeps optional fields absent rather than undefined", () => {
|
|
54
|
+
expect(
|
|
55
|
+
decodeSendToUserPayloadV1({
|
|
56
|
+
type: "attachment",
|
|
57
|
+
url: "https://files.example/a",
|
|
58
|
+
}),
|
|
59
|
+
).toEqual({ type: "attachment", url: "https://files.example/a" });
|
|
60
|
+
expect(
|
|
61
|
+
decodeSendToUserPayloadV1({
|
|
62
|
+
type: "widget",
|
|
63
|
+
widget: { prompt: "Go?", options: ["Yes"] },
|
|
64
|
+
}),
|
|
65
|
+
).toEqual({ type: "widget", widget: { prompt: "Go?", options: ["Yes"] } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("bounds an approval and refuses everything outside its exact keys", () => {
|
|
69
|
+
const approval = {
|
|
70
|
+
type: "approval" as const,
|
|
71
|
+
approvalId: "ap-1",
|
|
72
|
+
action: "Restart the host",
|
|
73
|
+
risk: "medium" as const,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// A card with no rationale and no window is the common case, and the
|
|
77
|
+
// absent fields stay absent rather than becoming undefined.
|
|
78
|
+
expect(decodeSendToUserPayloadV1(approval)).toEqual(approval);
|
|
79
|
+
expect(() =>
|
|
80
|
+
decodeSendToUserPayloadV1({ ...approval, risk: "catastrophic" }),
|
|
81
|
+
).toThrow("risk must be low, medium or high");
|
|
82
|
+
expect(() =>
|
|
83
|
+
decodeSendToUserPayloadV1({ ...approval, decision: "approved" }),
|
|
84
|
+
).toThrow('unexpected key "decision"');
|
|
85
|
+
expect(() =>
|
|
86
|
+
decodeSendToUserPayloadV1({ ...approval, expiresInSeconds: 0 }),
|
|
87
|
+
).toThrow("expiresInSeconds");
|
|
88
|
+
expect(() =>
|
|
89
|
+
decodeSendToUserPayloadV1({ ...approval, expiresInSeconds: 1.5 }),
|
|
90
|
+
).toThrow("expiresInSeconds");
|
|
91
|
+
// The id is a URL path segment and a durable key, so it is narrower than a
|
|
92
|
+
// bounded string: an id that cannot be addressed cannot be answered.
|
|
93
|
+
expect(() =>
|
|
94
|
+
decodeSendToUserPayloadV1({ ...approval, approvalId: "ap 1" }),
|
|
95
|
+
).toThrow("approvalId must be letters");
|
|
96
|
+
expect(() =>
|
|
97
|
+
decodeSendToUserPayloadV1({
|
|
98
|
+
...approval,
|
|
99
|
+
approvalId: "a".repeat(SEND_TO_USER_LIMITS_V1.approvalId + 1),
|
|
100
|
+
}),
|
|
101
|
+
).toThrow("approvalId exceeds");
|
|
102
|
+
expect(() =>
|
|
103
|
+
decodeSendToUserPayloadV1({
|
|
104
|
+
...approval,
|
|
105
|
+
action: "a".repeat(SEND_TO_USER_LIMITS_V1.action + 1),
|
|
106
|
+
}),
|
|
107
|
+
).toThrow("action exceeds");
|
|
108
|
+
const { risk: _risk, ...withoutRisk } = approval;
|
|
109
|
+
expect(() => decodeSendToUserPayloadV1(withoutRisk)).toThrow("risk");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("refuses an unknown type, a missing field and an extra key", () => {
|
|
113
|
+
expect(() =>
|
|
114
|
+
decodeSendToUserPayloadV1({ type: "sms", text: "hi" }),
|
|
115
|
+
).toThrow("send payload.type is invalid");
|
|
116
|
+
expect(() => decodeSendToUserPayloadV1({ type: "text" })).toThrow(
|
|
117
|
+
"send payload.text must be a string",
|
|
118
|
+
);
|
|
119
|
+
expect(() =>
|
|
120
|
+
decodeSendToUserPayloadV1({ type: "text", text: "hi", tone: "warm" }),
|
|
121
|
+
).toThrow('send payload has an unexpected key "tone"');
|
|
122
|
+
expect(() => decodeSendToUserPayloadV1("text")).toThrow(
|
|
123
|
+
"send payload must be an object",
|
|
124
|
+
);
|
|
125
|
+
expect(() => decodeSendToUserPayloadV1(["text"])).toThrow(
|
|
126
|
+
"send payload must be an object",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("bounds the widget at one to six distinct options", () => {
|
|
131
|
+
const widget = (options: string[]) => ({
|
|
132
|
+
type: "widget",
|
|
133
|
+
widget: { prompt: "Pick", options },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(() => decodeSendToUserPayloadV1(widget([]))).toThrow(
|
|
137
|
+
"must hold 1 to 6 entries",
|
|
138
|
+
);
|
|
139
|
+
expect(() =>
|
|
140
|
+
decodeSendToUserPayloadV1(widget(["a", "b", "c", "d", "e", "f", "g"])),
|
|
141
|
+
).toThrow("must hold 1 to 6 entries");
|
|
142
|
+
expect(() => decodeSendToUserPayloadV1(widget(["a", "a"]))).toThrow(
|
|
143
|
+
"send payload.widget.options has duplicates",
|
|
144
|
+
);
|
|
145
|
+
expect(
|
|
146
|
+
decodeSendToUserPayloadV1(widget(["a", "b", "c", "d", "e", "f"])),
|
|
147
|
+
).toMatchObject({ type: "widget" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("bounds every string it carries", () => {
|
|
151
|
+
const limits = SEND_TO_USER_LIMITS_V1;
|
|
152
|
+
expect(() =>
|
|
153
|
+
decodeSendToUserPayloadV1({
|
|
154
|
+
type: "text",
|
|
155
|
+
text: "x".repeat(limits.text + 1),
|
|
156
|
+
}),
|
|
157
|
+
).toThrow(`exceeds ${limits.text} characters`);
|
|
158
|
+
expect(() => decodeSendToUserPayloadV1({ type: "text", text: "" })).toThrow(
|
|
159
|
+
"send payload.text must not be empty",
|
|
160
|
+
);
|
|
161
|
+
expect(() =>
|
|
162
|
+
decodeSendToUserPayloadV1({
|
|
163
|
+
type: "widget",
|
|
164
|
+
widget: { prompt: "p", options: ["x".repeat(limits.option + 1)] },
|
|
165
|
+
}),
|
|
166
|
+
).toThrow(`exceeds ${limits.option} characters`);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("refuses an attachment that is not an absolute http URL", () => {
|
|
170
|
+
for (const url of ["/local/file.pdf", "not a url"]) {
|
|
171
|
+
expect(() =>
|
|
172
|
+
decodeSendToUserPayloadV1({ type: "attachment", url }),
|
|
173
|
+
).toThrow("send payload.url must be an absolute URL");
|
|
174
|
+
}
|
|
175
|
+
expect(() =>
|
|
176
|
+
decodeSendToUserPayloadV1({
|
|
177
|
+
type: "attachment",
|
|
178
|
+
url: "javascript:alert(1)",
|
|
179
|
+
}),
|
|
180
|
+
).toThrow("send payload.url must be an http or https URL");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("names the field it refused", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
decodeSendToUserPayloadV1(
|
|
186
|
+
{ type: "text", text: 1 },
|
|
187
|
+
"send_to_user.input",
|
|
188
|
+
),
|
|
189
|
+
).toThrow("send_to_user.input.text must be a string");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("the durable send and hand-off events", () => {
|
|
194
|
+
test("decodes a recorded send and rejects a malformed payload", () => {
|
|
195
|
+
const send = event({
|
|
196
|
+
type: "send/to-user",
|
|
197
|
+
turn: 4,
|
|
198
|
+
step: 2,
|
|
199
|
+
occurrenceId: "tool:4:2:0",
|
|
200
|
+
payload: { type: "text", text: "Done." },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(decodeSessionEvent(send)).toEqual(send as never);
|
|
204
|
+
expect(() =>
|
|
205
|
+
decodeSessionEvent({ ...send, payload: { type: "text" } }),
|
|
206
|
+
).toThrow("session event.payload.text must be a string");
|
|
207
|
+
expect(() => decodeSessionEvent({ ...send, occurrenceId: 4 })).toThrow(
|
|
208
|
+
"session event.occurrenceId",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("decodes a recorded hand-off and requires its exact keys", () => {
|
|
213
|
+
const wake = event({
|
|
214
|
+
type: "wake/parent",
|
|
215
|
+
turn: 4,
|
|
216
|
+
step: 2,
|
|
217
|
+
occurrenceId: "tool:4:2:0",
|
|
218
|
+
message: "The invoice is paid.",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(decodeSessionEvent(wake)).toEqual(wake as never);
|
|
222
|
+
expect(() =>
|
|
223
|
+
decodeSessionEvent({ ...wake, payload: { type: "text", text: "x" } }),
|
|
224
|
+
).toThrow();
|
|
225
|
+
const { message: _message, ...withoutMessage } = wake;
|
|
226
|
+
expect(() => decodeSessionEvent(withoutMessage)).toThrow();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("leaves an event recorded before sends existed decoding unchanged", () => {
|
|
230
|
+
const started = event({ type: "step/start", turn: 4, step: 2 });
|
|
231
|
+
|
|
232
|
+
expect(decodeSessionEvent(started)).toEqual(started as never);
|
|
233
|
+
});
|
|
234
|
+
});
|