@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/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
package/src/skills.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// How a Turn names one Skill.
|
|
2
|
+
//
|
|
3
|
+
// A `SkillRefV1` is the wire identity of a Skill: it crosses the client's turn
|
|
4
|
+
// command, the Bot Durable Object's run RPC, the Agent loop's queued input, and
|
|
5
|
+
// the durable session log. That is why it lives in the kernel rather than in
|
|
6
|
+
// `plugin-skills` — "Cross-runtime communication uses narrow, versioned DTOs,
|
|
7
|
+
// and every inbound value is decoded at its seam", and the seam here is a
|
|
8
|
+
// kernel event. What a ref *resolves to* is Package policy and stays in
|
|
9
|
+
// `plugin-skills`: the kernel holds the name and no opinion about the file.
|
|
10
|
+
//
|
|
11
|
+
// All four sources are declared at once even though only `bot` has a producer
|
|
12
|
+
// today. The value is durable — it is recorded in `input/queued` and in
|
|
13
|
+
// `skill/invoked` — so admitting a new source later would be a wire change in
|
|
14
|
+
// every decoder between the composer and the event log. Declaring them now
|
|
15
|
+
// means the Skills reach that adds User-global, managed and plugin-borne
|
|
16
|
+
// Skills adds no codec change at all.
|
|
17
|
+
|
|
18
|
+
/** Where a Skill comes from. Only `bot` has a producer today. */
|
|
19
|
+
export type SkillRefSourceV1 = "bot" | "user" | "managed" | "plugin";
|
|
20
|
+
|
|
21
|
+
/** The declared sources, in the catalog's canonical ordering. */
|
|
22
|
+
export const SKILL_REF_SOURCES_V1: readonly SkillRefSourceV1[] = [
|
|
23
|
+
"bot",
|
|
24
|
+
"user",
|
|
25
|
+
"managed",
|
|
26
|
+
"plugin",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One Skill named for invocation.
|
|
31
|
+
*
|
|
32
|
+
* `packageId` is present exactly when `source` is `plugin`: a plugin-borne
|
|
33
|
+
* Skill is only unique within the Package that ships it, and every other
|
|
34
|
+
* source is unique on its slug alone. Refs are therefore globally unique and
|
|
35
|
+
* there is no shadowing rule.
|
|
36
|
+
*/
|
|
37
|
+
export interface SkillRefV1 {
|
|
38
|
+
schemaVersion: 1;
|
|
39
|
+
source: SkillRefSourceV1;
|
|
40
|
+
slug: string;
|
|
41
|
+
packageId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Most Skills one Turn may invoke. */
|
|
45
|
+
export const MAX_INVOKED_SKILLS_V1 = 3;
|
|
46
|
+
|
|
47
|
+
const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
48
|
+
const SKILL_PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
49
|
+
|
|
50
|
+
/** True when a slug is well formed. Total; never throws. */
|
|
51
|
+
export function isSkillRefSlugV1(value: unknown): value is string {
|
|
52
|
+
return typeof value === "string" && SKILL_SLUG_PATTERN.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The canonical string form: `bot/<slug>`, `plugin/<packageId>/<slug>`. */
|
|
56
|
+
export function formatSkillRefV1(ref: SkillRefV1): string {
|
|
57
|
+
return ref.source === "plugin"
|
|
58
|
+
? `plugin/${ref.packageId}/${ref.slug}`
|
|
59
|
+
: `${ref.source}/${ref.slug}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads the canonical string form back. Returns `undefined` rather than
|
|
64
|
+
* throwing: a ref arriving as text is untrusted input like any other.
|
|
65
|
+
*/
|
|
66
|
+
export function parseSkillRefV1(value: unknown): SkillRefV1 | undefined {
|
|
67
|
+
if (typeof value !== "string") return undefined;
|
|
68
|
+
const segments = value.split("/");
|
|
69
|
+
const source = SKILL_REF_SOURCES_V1.find(
|
|
70
|
+
(candidate) => candidate === segments[0],
|
|
71
|
+
);
|
|
72
|
+
if (!source) return undefined;
|
|
73
|
+
if (source === "plugin") {
|
|
74
|
+
if (segments.length !== 3) return undefined;
|
|
75
|
+
const packageId = segments[1] ?? "";
|
|
76
|
+
const slug = segments[2] ?? "";
|
|
77
|
+
if (!SKILL_PACKAGE_ID_PATTERN.test(packageId)) return undefined;
|
|
78
|
+
if (!SKILL_SLUG_PATTERN.test(slug)) return undefined;
|
|
79
|
+
return { schemaVersion: 1, source, slug, packageId };
|
|
80
|
+
}
|
|
81
|
+
if (segments.length !== 2) return undefined;
|
|
82
|
+
const slug = segments[1] ?? "";
|
|
83
|
+
if (!SKILL_SLUG_PATTERN.test(slug)) return undefined;
|
|
84
|
+
return { schemaVersion: 1, source, slug };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The strict decoder for one ref crossing a seam. Exact keys: an unknown field
|
|
89
|
+
* is a refusal, never a value carried through to durable state.
|
|
90
|
+
*/
|
|
91
|
+
export function decodeSkillRefV1(
|
|
92
|
+
value: unknown,
|
|
93
|
+
label = "skill ref",
|
|
94
|
+
): SkillRefV1 {
|
|
95
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
96
|
+
throw new Error(`${label} must be an object`);
|
|
97
|
+
}
|
|
98
|
+
const candidate = value as Record<string, unknown>;
|
|
99
|
+
const allowed = new Set(["schemaVersion", "source", "slug", "packageId"]);
|
|
100
|
+
for (const key of Reflect.ownKeys(candidate)) {
|
|
101
|
+
if (typeof key !== "string" || !allowed.has(key)) {
|
|
102
|
+
throw new Error(`${label} has unknown fields`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (candidate.schemaVersion !== 1) {
|
|
106
|
+
throw new Error(`${label}.schemaVersion is invalid`);
|
|
107
|
+
}
|
|
108
|
+
const source = SKILL_REF_SOURCES_V1.find(
|
|
109
|
+
(declared) => declared === candidate.source,
|
|
110
|
+
);
|
|
111
|
+
if (!source) throw new Error(`${label}.source is invalid`);
|
|
112
|
+
if (!isSkillRefSlugV1(candidate.slug)) {
|
|
113
|
+
throw new Error(`${label}.slug is invalid`);
|
|
114
|
+
}
|
|
115
|
+
if (source === "plugin") {
|
|
116
|
+
if (
|
|
117
|
+
typeof candidate.packageId !== "string" ||
|
|
118
|
+
!SKILL_PACKAGE_ID_PATTERN.test(candidate.packageId)
|
|
119
|
+
) {
|
|
120
|
+
throw new Error(`${label}.packageId is invalid`);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
source,
|
|
125
|
+
slug: candidate.slug,
|
|
126
|
+
packageId: candidate.packageId,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (candidate.packageId !== undefined) {
|
|
130
|
+
// A packageId on a non-plugin ref would name a Package that has nothing to
|
|
131
|
+
// do with the Skill, so it is a refusal rather than an ignored field.
|
|
132
|
+
throw new Error(`${label}.packageId is only valid on a plugin Skill`);
|
|
133
|
+
}
|
|
134
|
+
return { schemaVersion: 1, source, slug: candidate.slug };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The strict decoder for the list one Turn invokes. Bounded at
|
|
139
|
+
* {@link MAX_INVOKED_SKILLS_V1}, and duplicates are refused: invoking the same
|
|
140
|
+
* Skill twice would expand its body twice with no way to say which won.
|
|
141
|
+
*/
|
|
142
|
+
export function decodeSkillRefsV1(
|
|
143
|
+
value: unknown,
|
|
144
|
+
label = "skill refs",
|
|
145
|
+
): SkillRefV1[] {
|
|
146
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
147
|
+
if (value.length > MAX_INVOKED_SKILLS_V1) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`${label} may name at most ${MAX_INVOKED_SKILLS_V1} Skills`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const refs = value.map((entry, index) =>
|
|
153
|
+
decodeSkillRefV1(entry, `${label}[${index}]`),
|
|
154
|
+
);
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
for (const ref of refs) {
|
|
157
|
+
const canonical = formatSkillRefV1(ref);
|
|
158
|
+
if (seen.has(canonical)) {
|
|
159
|
+
throw new Error(`${label} names "${canonical}" more than once`);
|
|
160
|
+
}
|
|
161
|
+
seen.add(canonical);
|
|
162
|
+
}
|
|
163
|
+
return refs;
|
|
164
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tool result attachments: what may be recorded, and what may not.
|
|
2
|
+
//
|
|
3
|
+
// The one rule worth a suite of its own is that resolved bytes are never
|
|
4
|
+
// durable. The session event log is one Durable Object value; a base64
|
|
5
|
+
// screenshot recorded in it would be a record that grows past what the object
|
|
6
|
+
// can hold, so the decoder refuses `dataBase64` on the durable side rather
|
|
7
|
+
// than trimming it somewhere further down.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
decodeToolAttachmentsV1,
|
|
11
|
+
decodeSessionEvent,
|
|
12
|
+
type ToolAttachmentV1,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
const HASH = "a".repeat(64);
|
|
16
|
+
|
|
17
|
+
const attachment: ToolAttachmentV1 = {
|
|
18
|
+
kind: "image",
|
|
19
|
+
mediaType: "image/png",
|
|
20
|
+
workspacePath: {
|
|
21
|
+
root: {
|
|
22
|
+
kind: "package-declared",
|
|
23
|
+
userId: "user-1",
|
|
24
|
+
packageId: "computer",
|
|
25
|
+
rootId: "screenshots",
|
|
26
|
+
},
|
|
27
|
+
path: "bot-1/run-9-1.png",
|
|
28
|
+
},
|
|
29
|
+
contentHash: HASH,
|
|
30
|
+
bytes: 2048,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("decodeToolAttachmentsV1", () => {
|
|
34
|
+
test("accepts an exact image reference", () => {
|
|
35
|
+
expect(decodeToolAttachmentsV1([attachment], "attachments", true)).toEqual([
|
|
36
|
+
attachment,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("refuses resolved bytes on the durable side and accepts them in a request", () => {
|
|
41
|
+
const resolved = [{ ...attachment, dataBase64: "AAAA" }];
|
|
42
|
+
expect(() =>
|
|
43
|
+
decodeToolAttachmentsV1(resolved, "attachments", true),
|
|
44
|
+
).toThrow(/never durable/);
|
|
45
|
+
expect(decodeToolAttachmentsV1(resolved, "attachments", false)).toEqual(
|
|
46
|
+
resolved,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("refuses an unknown media type, a bad hash, and an unknown field", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
decodeToolAttachmentsV1(
|
|
53
|
+
[{ ...attachment, mediaType: "application/pdf" }],
|
|
54
|
+
"attachments",
|
|
55
|
+
true,
|
|
56
|
+
),
|
|
57
|
+
).toThrow(/mediaType/);
|
|
58
|
+
expect(() =>
|
|
59
|
+
decodeToolAttachmentsV1(
|
|
60
|
+
[{ ...attachment, contentHash: "short" }],
|
|
61
|
+
"attachments",
|
|
62
|
+
true,
|
|
63
|
+
),
|
|
64
|
+
).toThrow(/sha-256/);
|
|
65
|
+
expect(() =>
|
|
66
|
+
decodeToolAttachmentsV1(
|
|
67
|
+
[{ ...attachment, extra: 1 }],
|
|
68
|
+
"attachments",
|
|
69
|
+
true,
|
|
70
|
+
),
|
|
71
|
+
).toThrow(/invalid fields/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("a recorded tool result", () => {
|
|
76
|
+
test("carries its attachments through the session event decoder", () => {
|
|
77
|
+
const event = {
|
|
78
|
+
type: "tool/result" as const,
|
|
79
|
+
seq: 0,
|
|
80
|
+
timestamp: "2026-08-31T00:00:00.000Z",
|
|
81
|
+
turn: 1,
|
|
82
|
+
step: 1,
|
|
83
|
+
occurrenceId: "1:1:0",
|
|
84
|
+
name: "computer_screenshot",
|
|
85
|
+
content: "{}",
|
|
86
|
+
isError: false,
|
|
87
|
+
status: "completed" as const,
|
|
88
|
+
attachments: [attachment],
|
|
89
|
+
};
|
|
90
|
+
expect(decodeSessionEvent(event)).toMatchObject({
|
|
91
|
+
attachments: [attachment],
|
|
92
|
+
});
|
|
93
|
+
expect(() =>
|
|
94
|
+
decodeSessionEvent({
|
|
95
|
+
...event,
|
|
96
|
+
attachments: [{ ...attachment, dataBase64: "AAAA" }],
|
|
97
|
+
}),
|
|
98
|
+
).toThrow(/never durable/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Importing the augmented module is what merges these declarations into cordis.
|
|
2
|
+
import type {} from "cordis";
|
|
3
|
+
import {
|
|
4
|
+
TURN_TYPES_V1,
|
|
5
|
+
type ToolAttachmentV1,
|
|
6
|
+
type ToolCall,
|
|
7
|
+
type ToolSchema,
|
|
8
|
+
type TurnTypeV1,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ToolExecutionContext {
|
|
12
|
+
botId: string;
|
|
13
|
+
agentId: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
compositionGenerationId: string;
|
|
16
|
+
/** Stable durable occurrence identity for provider idempotency and recovery. */
|
|
17
|
+
effectId: string;
|
|
18
|
+
/** Exact durable call; reconciliation fails closed when it is absent. */
|
|
19
|
+
toolCall?: ToolCall;
|
|
20
|
+
/** The turn type this Turn was admitted as. */
|
|
21
|
+
turnType: TurnTypeV1;
|
|
22
|
+
/**
|
|
23
|
+
* The subagent role this Turn was admitted under, on a `subagent` Turn that
|
|
24
|
+
* declared one. An opaque string here for the same reason `turnType` is: the
|
|
25
|
+
* kernel carries it and narrows the catalog by it, and what any role name
|
|
26
|
+
* means is Package policy.
|
|
27
|
+
*/
|
|
28
|
+
subagentRole?: string;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolExecutionResult {
|
|
33
|
+
content: string;
|
|
34
|
+
isError: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The Turn ends once this result is recorded: the Agent loop closes the step
|
|
37
|
+
* as `completed` and makes no further model request. Declared per *result*,
|
|
38
|
+
* not per definition, because one tool can end a Turn for one payload and
|
|
39
|
+
* not another. The loop carries the boolean; what earns it is Package
|
|
40
|
+
* policy.
|
|
41
|
+
*/
|
|
42
|
+
endsTurn?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Binaries this result produced, named by their durable Workspace path.
|
|
45
|
+
*
|
|
46
|
+
* They reach the model only where the model-invocation adapter can show
|
|
47
|
+
* them; an adapter that cannot drops them and says so in the text, so a tool
|
|
48
|
+
* that returns an image is never silently answered with nothing.
|
|
49
|
+
*/
|
|
50
|
+
attachments?: ToolAttachmentV1[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The turn types — and, optionally, the subagent roles — an admission names. */
|
|
54
|
+
export interface TurnAdmissionV1 {
|
|
55
|
+
/**
|
|
56
|
+
* The turn types this tool is offered on. Optional, so a declaration can
|
|
57
|
+
* narrow the *role* dimension alone: a work tool that every turn type may
|
|
58
|
+
* call but only an `executor` subagent may reach says exactly that, and does
|
|
59
|
+
* not have to restate the full turn-type list to do it.
|
|
60
|
+
*/
|
|
61
|
+
turnTypes?: TurnTypeV1[];
|
|
62
|
+
/**
|
|
63
|
+
* The subagent roles this tool is offered to on a `subagent` Turn. Absent
|
|
64
|
+
* means every role, exactly as an absent `admission` means every turn type:
|
|
65
|
+
* narrowing is always something a declaration *does*, never something the
|
|
66
|
+
* kernel assumes. The strings are opaque here.
|
|
67
|
+
*/
|
|
68
|
+
subagentRoles?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The turn types a registered tool may be offered on: its own declaration,
|
|
73
|
+
* bounded by the durable manifest ceiling of the Capability that contributed
|
|
74
|
+
* it. An absent declaration is every turn type — every tool shipped today is a
|
|
75
|
+
* work tool — and an absent ceiling is a manifest that set no bound. The
|
|
76
|
+
* result keeps {@link TURN_TYPES_V1} order and holds no duplicates.
|
|
77
|
+
*/
|
|
78
|
+
export function admittedTurnTypesV1(
|
|
79
|
+
declared: readonly TurnTypeV1[] | undefined,
|
|
80
|
+
ceiling: readonly TurnTypeV1[] | undefined,
|
|
81
|
+
): TurnTypeV1[] {
|
|
82
|
+
return TURN_TYPES_V1.filter(
|
|
83
|
+
(turnType) =>
|
|
84
|
+
(declared === undefined || declared.includes(turnType)) &&
|
|
85
|
+
(ceiling === undefined || ceiling.includes(turnType)),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The subagent roles a registered tool may be offered to: its own declaration,
|
|
91
|
+
* bounded by the durable manifest ceiling of the Capability that contributed
|
|
92
|
+
* it. `undefined` — both absent — is every role. The result is deduplicated
|
|
93
|
+
* and keeps the declaration's order.
|
|
94
|
+
*/
|
|
95
|
+
export function admittedSubagentRolesV1(
|
|
96
|
+
declared: readonly string[] | undefined,
|
|
97
|
+
ceiling: readonly string[] | undefined,
|
|
98
|
+
): readonly string[] | undefined {
|
|
99
|
+
if (declared === undefined && ceiling === undefined) return undefined;
|
|
100
|
+
const source = declared ?? ceiling ?? [];
|
|
101
|
+
const bound = declared === undefined ? undefined : ceiling;
|
|
102
|
+
const admitted: string[] = [];
|
|
103
|
+
for (const role of source) {
|
|
104
|
+
if (bound !== undefined && !bound.includes(role)) continue;
|
|
105
|
+
if (!admitted.includes(role)) admitted.push(role);
|
|
106
|
+
}
|
|
107
|
+
return admitted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether a tool with these admitted roles is offered to one Turn's role. A
|
|
112
|
+
* Turn that names no role is not narrowed at all — role is a *second* ceiling
|
|
113
|
+
* dimension, and a Turn outside the subagent world has no coordinate on it.
|
|
114
|
+
*/
|
|
115
|
+
export function isSubagentRoleAdmittedV1(
|
|
116
|
+
admitted: readonly string[] | undefined,
|
|
117
|
+
role: string | undefined,
|
|
118
|
+
): boolean {
|
|
119
|
+
if (role === undefined || admitted === undefined) return true;
|
|
120
|
+
return admitted.includes(role);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ToolEffectReconciliation =
|
|
124
|
+
| { status: "recovered"; result: ToolExecutionResult }
|
|
125
|
+
| { status: "unavailable"; reason: string };
|
|
126
|
+
|
|
127
|
+
export interface ToolDefinition extends ToolSchema {
|
|
128
|
+
idempotent?: boolean;
|
|
129
|
+
/** The turn types this tool is offered on. Absent means all of them. */
|
|
130
|
+
admission?: TurnAdmissionV1;
|
|
131
|
+
validate?(input: unknown): boolean;
|
|
132
|
+
execute(
|
|
133
|
+
input: unknown,
|
|
134
|
+
context: ToolExecutionContext,
|
|
135
|
+
): Promise<ToolExecutionResult>;
|
|
136
|
+
reconcile?(
|
|
137
|
+
input: unknown,
|
|
138
|
+
context: ToolExecutionContext,
|
|
139
|
+
): Promise<ToolEffectReconciliation>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type ToolPreparation =
|
|
143
|
+
| { kind: "ready"; call: ToolCall; idempotent: boolean }
|
|
144
|
+
| { kind: "denied"; call: ToolCall; result: ToolExecutionResult };
|
|
145
|
+
|
|
146
|
+
/** The kernel-declared tool execution interface. Implemented by a Package. */
|
|
147
|
+
export interface ToolExecution {
|
|
148
|
+
/** The catalog trimmed to what this turn type — and role — admits. */
|
|
149
|
+
schemas(admission: {
|
|
150
|
+
turnType: TurnTypeV1;
|
|
151
|
+
subagentRole?: string;
|
|
152
|
+
}): ToolSchema[];
|
|
153
|
+
prepare(
|
|
154
|
+
call: ToolCall,
|
|
155
|
+
context: ToolExecutionContext,
|
|
156
|
+
): Promise<ToolPreparation>;
|
|
157
|
+
executePrepared(
|
|
158
|
+
preparation: Extract<ToolPreparation, { kind: "ready" }>,
|
|
159
|
+
context: ToolExecutionContext,
|
|
160
|
+
): Promise<ToolExecutionResult>;
|
|
161
|
+
/**
|
|
162
|
+
* Recovers the outcome of an already-admitted tool effect without starting a
|
|
163
|
+
* second one, so an interrupted Turn never duplicates a side effect.
|
|
164
|
+
*/
|
|
165
|
+
reconcilePrepared(
|
|
166
|
+
preparation: Extract<ToolPreparation, { kind: "ready" }>,
|
|
167
|
+
context: ToolExecutionContext,
|
|
168
|
+
): Promise<ToolEffectReconciliation>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What the host that mounts a Contribution knows about the tool and the
|
|
173
|
+
* Package's manifest, which the tool itself cannot be trusted to restate.
|
|
174
|
+
*/
|
|
175
|
+
export interface ToolRegistrationOptions {
|
|
176
|
+
/**
|
|
177
|
+
* The durable manifest ceiling of the Capability contributing this tool. A
|
|
178
|
+
* tool may not be admitted onto a turn type its manifest does not list.
|
|
179
|
+
*/
|
|
180
|
+
admissionCeiling?: readonly TurnTypeV1[];
|
|
181
|
+
/**
|
|
182
|
+
* The same durable ceiling on the second dimension: the subagent roles the
|
|
183
|
+
* Capability's manifest lists. Absent is a manifest that set no bound.
|
|
184
|
+
*/
|
|
185
|
+
subagentRoleCeiling?: readonly string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Contributing Packages register tool definitions through this surface. */
|
|
189
|
+
export interface ToolRegistration {
|
|
190
|
+
register(
|
|
191
|
+
definition: ToolDefinition,
|
|
192
|
+
options?: ToolRegistrationOptions,
|
|
193
|
+
): () => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
declare module "cordis" {
|
|
197
|
+
interface Context {
|
|
198
|
+
tools: ToolExecution & ToolRegistration;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface Events {
|
|
202
|
+
"tools/pre-execute": (
|
|
203
|
+
call: ToolCall,
|
|
204
|
+
context: ToolExecutionContext,
|
|
205
|
+
next: () => Promise<ToolPreparation>,
|
|
206
|
+
) => Promise<ToolPreparation>;
|
|
207
|
+
"tools/execute": (
|
|
208
|
+
call: ToolCall,
|
|
209
|
+
context: ToolExecutionContext,
|
|
210
|
+
next: () => Promise<ToolExecutionResult>,
|
|
211
|
+
) => Promise<ToolExecutionResult>;
|
|
212
|
+
"tools/post-execute": (
|
|
213
|
+
call: ToolCall,
|
|
214
|
+
result: ToolExecutionResult,
|
|
215
|
+
context: ToolExecutionContext,
|
|
216
|
+
next: () => Promise<ToolExecutionResult>,
|
|
217
|
+
) => Promise<ToolExecutionResult>;
|
|
218
|
+
"tools/result": (call: ToolCall, result: ToolExecutionResult) => void;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { currentTurnV1, messageTurnsV1 } from "./turn-history.js";
|
|
3
|
+
import type { SessionEvent } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const events: SessionEvent[] = [
|
|
6
|
+
{
|
|
7
|
+
type: "turn/start",
|
|
8
|
+
turn: 1,
|
|
9
|
+
seq: 0,
|
|
10
|
+
timestamp: "2026-09-01T00:00:00.000Z",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
type: "user/message",
|
|
14
|
+
turn: 1,
|
|
15
|
+
step: 0,
|
|
16
|
+
messageId: "m-1",
|
|
17
|
+
text: "first",
|
|
18
|
+
seq: 1,
|
|
19
|
+
timestamp: "2026-09-01T00:00:01.000Z",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "assistant/message",
|
|
23
|
+
turn: 1,
|
|
24
|
+
step: 1,
|
|
25
|
+
text: "answer",
|
|
26
|
+
toolCalls: [],
|
|
27
|
+
requestId: "r-1",
|
|
28
|
+
seq: 2,
|
|
29
|
+
timestamp: "2026-09-01T00:00:02.000Z",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: "turn/start",
|
|
33
|
+
turn: 2,
|
|
34
|
+
seq: 3,
|
|
35
|
+
timestamp: "2026-09-01T00:00:03.000Z",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: "user/message",
|
|
39
|
+
turn: 2,
|
|
40
|
+
step: 0,
|
|
41
|
+
messageId: "m-2",
|
|
42
|
+
text: "second",
|
|
43
|
+
seq: 4,
|
|
44
|
+
timestamp: "2026-09-01T00:00:04.000Z",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
describe("turn history", () => {
|
|
49
|
+
test("names the Turn each derived message belongs to", () => {
|
|
50
|
+
expect(messageTurnsV1(events)).toEqual([1, 1, 2]);
|
|
51
|
+
expect(currentTurnV1(events)).toBe(2);
|
|
52
|
+
expect(currentTurnV1([])).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Which of a Session's derived messages belong to the Turn being assembled.
|
|
2
|
+
//
|
|
3
|
+
// The Bot Durable Object keeps one ordered event log per Bot, and the kernel
|
|
4
|
+
// enforces its contiguity: a Turn is always seeded with the whole history so
|
|
5
|
+
// its events keep their sequence. "What enters a model request is Package
|
|
6
|
+
// policy", so the *narrowing* happens when the request is assembled, and these
|
|
7
|
+
// are the two mechanical facts every narrowing policy needs — which Turn each
|
|
8
|
+
// derived message belongs to, and which Turn is currently open.
|
|
9
|
+
//
|
|
10
|
+
import type { SessionEvent } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** The event types `Session.deriveMessages` turns into a message, in order. */
|
|
13
|
+
const MESSAGE_EVENT_TYPES = new Set([
|
|
14
|
+
"user/message",
|
|
15
|
+
"assistant/message",
|
|
16
|
+
"tool/result",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** The Turn each derived message belongs to, in the order they were derived. */
|
|
20
|
+
export function messageTurnsV1(events: readonly SessionEvent[]): number[] {
|
|
21
|
+
const turns: number[] = [];
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
if (!MESSAGE_EVENT_TYPES.has(event.type)) continue;
|
|
24
|
+
turns.push("turn" in event ? event.turn : 0);
|
|
25
|
+
}
|
|
26
|
+
return turns;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The Turn a request is being assembled inside: the last one started. */
|
|
30
|
+
export function currentTurnV1(events: readonly SessionEvent[]): number {
|
|
31
|
+
const started = events.findLast((event) => event.type === "turn/start");
|
|
32
|
+
return started?.type === "turn/start" ? started.turn : 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
admittedTurnTypesV1,
|
|
4
|
+
decodeSessionEvent,
|
|
5
|
+
decodeTurnTypeV1,
|
|
6
|
+
TURN_TYPES_V1,
|
|
7
|
+
type SessionEvent,
|
|
8
|
+
} from "./index.js";
|
|
9
|
+
|
|
10
|
+
const timestamp = "2026-08-31T00:00:00.000Z";
|
|
11
|
+
|
|
12
|
+
function durable(event: Record<string, unknown>): unknown {
|
|
13
|
+
return { ...event, seq: 0, timestamp };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("TurnTypeV1", () => {
|
|
17
|
+
test("names every turn type the admission vocabulary declares", () => {
|
|
18
|
+
expect([...TURN_TYPES_V1]).toEqual(["chat", "automation", "subagent"]);
|
|
19
|
+
for (const turnType of TURN_TYPES_V1) {
|
|
20
|
+
expect(decodeTurnTypeV1(turnType)).toBe(turnType);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects a value outside the declared vocabulary", () => {
|
|
25
|
+
for (const invalid of ["Chat", "routine", "", 1, null, undefined, {}]) {
|
|
26
|
+
expect(() => decodeTurnTypeV1(invalid)).toThrow(/turn type is invalid/);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("turn/admission", () => {
|
|
32
|
+
test("decodes the admitted turn type of a Turn", () => {
|
|
33
|
+
const event = durable({
|
|
34
|
+
type: "turn/admission",
|
|
35
|
+
turn: 3,
|
|
36
|
+
turnType: "automation",
|
|
37
|
+
});
|
|
38
|
+
expect(decodeSessionEvent(event)).toMatchObject({
|
|
39
|
+
type: "turn/admission",
|
|
40
|
+
turn: 3,
|
|
41
|
+
turnType: "automation",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects an unknown turn type and any extra or missing key", () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
decodeSessionEvent(
|
|
48
|
+
durable({ type: "turn/admission", turn: 1, turnType: "routine" }),
|
|
49
|
+
),
|
|
50
|
+
).toThrow(/turnType is invalid/);
|
|
51
|
+
expect(() =>
|
|
52
|
+
decodeSessionEvent(durable({ type: "turn/admission", turn: 1 })),
|
|
53
|
+
).toThrow(/invalid fields/);
|
|
54
|
+
expect(() =>
|
|
55
|
+
decodeSessionEvent(
|
|
56
|
+
durable({
|
|
57
|
+
type: "turn/admission",
|
|
58
|
+
turn: 1,
|
|
59
|
+
turnType: "chat" as const,
|
|
60
|
+
extra: true,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
).toThrow(/invalid fields/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("leaves a pre-change turn/start decoding exactly as it did", () => {
|
|
67
|
+
const started: SessionEvent = decodeSessionEvent(
|
|
68
|
+
durable({ type: "turn/start", turn: 1 }),
|
|
69
|
+
);
|
|
70
|
+
expect(started).toMatchObject({ type: "turn/start", turn: 1 });
|
|
71
|
+
expect(() =>
|
|
72
|
+
decodeSessionEvent(
|
|
73
|
+
durable({ type: "turn/start", turn: 1, turnType: "chat" }),
|
|
74
|
+
),
|
|
75
|
+
).toThrow(/invalid fields/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("admittedTurnTypesV1", () => {
|
|
80
|
+
test("admits every turn type when neither the tool nor the manifest bounds it", () => {
|
|
81
|
+
expect(admittedTurnTypesV1(undefined, undefined)).toEqual([
|
|
82
|
+
...TURN_TYPES_V1,
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("narrows a tool declaration within the manifest ceiling", () => {
|
|
87
|
+
expect(
|
|
88
|
+
admittedTurnTypesV1(["chat", "automation"], ["automation", "subagent"]),
|
|
89
|
+
).toEqual(["automation"]);
|
|
90
|
+
expect(admittedTurnTypesV1(["chat"], undefined)).toEqual(["chat"]);
|
|
91
|
+
expect(admittedTurnTypesV1(undefined, ["automation"])).toEqual([
|
|
92
|
+
"automation",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("keeps the declared vocabulary order and drops duplicates", () => {
|
|
97
|
+
expect(
|
|
98
|
+
admittedTurnTypesV1(["subagent", "chat", "chat"], undefined),
|
|
99
|
+
).toEqual(["chat", "subagent"]);
|
|
100
|
+
});
|
|
101
|
+
});
|