@frockbot/plugin-shell 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/frockbot.json +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentEffectAdmission,
|
|
3
|
+
AgentHandle,
|
|
4
|
+
} from "@frockbot/kernel-agent-loop/agent";
|
|
5
|
+
import {
|
|
6
|
+
type PersistSessionEvents,
|
|
7
|
+
type SessionEvent,
|
|
8
|
+
type SkillRefV1,
|
|
9
|
+
turnFailureMessage,
|
|
10
|
+
type TurnTypeV1,
|
|
11
|
+
validateToolOccurrenceJournal,
|
|
12
|
+
} from "@frockbot/kernel-contracts";
|
|
13
|
+
import type { ShellMountedComposition } from "./backend-composition.js";
|
|
14
|
+
import {
|
|
15
|
+
BotTurnExecutionError,
|
|
16
|
+
BotTurnReconciliationRequiredError,
|
|
17
|
+
BotTurnRecoveryRequiredError,
|
|
18
|
+
} from "@frockbot/kernel-do";
|
|
19
|
+
import type { BotTurnCommand, BotTurnCompletion } from "./backend-contracts.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
BotTurnExecutionError,
|
|
23
|
+
BotTurnReconciliationRequiredError,
|
|
24
|
+
BotTurnRecoveryRequiredError,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function appendedSessionEvents(
|
|
28
|
+
previous: readonly SessionEvent[],
|
|
29
|
+
candidate: readonly SessionEvent[],
|
|
30
|
+
): SessionEvent[] {
|
|
31
|
+
if (
|
|
32
|
+
candidate.length < previous.length ||
|
|
33
|
+
previous.some(
|
|
34
|
+
(event, index) =>
|
|
35
|
+
JSON.stringify(event) !== JSON.stringify(candidate[index]),
|
|
36
|
+
)
|
|
37
|
+
) {
|
|
38
|
+
throw new Error("candidate changed durable session history");
|
|
39
|
+
}
|
|
40
|
+
return structuredClone(candidate.slice(previous.length));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classifies one finished Agent handle against the durable history it started
|
|
45
|
+
* from. Shared by the Composition-mounted and the resident execution paths, so
|
|
46
|
+
* both reach exactly the same durable terminal, recovery, or reconciliation
|
|
47
|
+
* outcome.
|
|
48
|
+
*/
|
|
49
|
+
function settleBotTurn(
|
|
50
|
+
handle: AgentHandle,
|
|
51
|
+
command: BotTurnCommand,
|
|
52
|
+
previousEvents: readonly SessionEvent[],
|
|
53
|
+
): BotTurnCompletion {
|
|
54
|
+
const events = [...handle.agent.session.events];
|
|
55
|
+
const turnStart = events.findLast((event) => event.type === "turn/start");
|
|
56
|
+
const currentTurn =
|
|
57
|
+
turnStart?.type === "turn/start" ? turnStart.turn : undefined;
|
|
58
|
+
const terminalTurn = events.findLast(
|
|
59
|
+
(event) => event.type === "turn/end" && event.turn === currentTurn,
|
|
60
|
+
);
|
|
61
|
+
if (!terminalTurn || terminalTurn.type !== "turn/end") {
|
|
62
|
+
const unresolvedTool = [
|
|
63
|
+
...validateToolOccurrenceJournal(events).values(),
|
|
64
|
+
].find((entry) => entry.intent && !entry.result);
|
|
65
|
+
if (unresolvedTool) {
|
|
66
|
+
throw new BotTurnReconciliationRequiredError(
|
|
67
|
+
`Tool effect "${unresolvedTool.occurrence.occurrenceId}" requires reconciliation`,
|
|
68
|
+
appendedSessionEvents(previousEvents, events),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const reconciliation = events.findLast(
|
|
72
|
+
(event) =>
|
|
73
|
+
event.type === "model/reconciliation-required" &&
|
|
74
|
+
event.turn === currentTurn,
|
|
75
|
+
);
|
|
76
|
+
if (reconciliation?.type === "model/reconciliation-required") {
|
|
77
|
+
throw new BotTurnReconciliationRequiredError(
|
|
78
|
+
reconciliation.reason,
|
|
79
|
+
appendedSessionEvents(previousEvents, events),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const latestRequest = events.findLast(
|
|
83
|
+
(event) => event.type === "model/request" && event.turn === currentTurn,
|
|
84
|
+
);
|
|
85
|
+
const hasDurableOutcome =
|
|
86
|
+
latestRequest?.type === "model/request" &&
|
|
87
|
+
events.some(
|
|
88
|
+
(event) =>
|
|
89
|
+
(event.type === "assistant/message" ||
|
|
90
|
+
event.type === "model/effect-not-started") &&
|
|
91
|
+
event.requestId === latestRequest.request.requestId,
|
|
92
|
+
);
|
|
93
|
+
if (hasDurableOutcome) {
|
|
94
|
+
throw new BotTurnRecoveryRequiredError(
|
|
95
|
+
appendedSessionEvents(previousEvents, events),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw new BotTurnExecutionError(
|
|
99
|
+
"Bot turn did not reach a durable terminal state",
|
|
100
|
+
appendedSessionEvents(previousEvents, events),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (terminalTurn.outcome !== "completed") {
|
|
104
|
+
throw new BotTurnExecutionError(
|
|
105
|
+
turnFailureMessage(terminalTurn.outcome, terminalTurn.reason),
|
|
106
|
+
appendedSessionEvents(previousEvents, events),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const message = handle.agent.session.deriveMessages().at(-1);
|
|
110
|
+
const assistantText = message?.role === "assistant" ? message.content : "";
|
|
111
|
+
return {
|
|
112
|
+
runId: command.runId,
|
|
113
|
+
// A Turn the Bot ended by speaking through `send_to_user` writes no
|
|
114
|
+
// assistant message at all, so the derived text falls back to the last
|
|
115
|
+
// text payload it sent. Every other payload leaves the text empty and
|
|
116
|
+
// reaches the client as a projected `send/to-user` event instead.
|
|
117
|
+
text: assistantText || lastSentTextV1(events),
|
|
118
|
+
events: appendedSessionEvents(previousEvents, events),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The last `text` payload the Turn sent, or `""` when it sent none. */
|
|
123
|
+
function lastSentTextV1(events: readonly SessionEvent[]): string {
|
|
124
|
+
const sent = events.findLast(
|
|
125
|
+
(event) => event.type === "send/to-user" && event.payload.type === "text",
|
|
126
|
+
);
|
|
127
|
+
return sent?.type === "send/to-user" && sent.payload.type === "text"
|
|
128
|
+
? sent.payload.text
|
|
129
|
+
: "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function turnExecutionError(
|
|
133
|
+
error: unknown,
|
|
134
|
+
previousEvents: readonly SessionEvent[],
|
|
135
|
+
events: readonly SessionEvent[],
|
|
136
|
+
): never {
|
|
137
|
+
if (
|
|
138
|
+
error instanceof BotTurnExecutionError ||
|
|
139
|
+
error instanceof BotTurnReconciliationRequiredError ||
|
|
140
|
+
error instanceof BotTurnRecoveryRequiredError
|
|
141
|
+
) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
throw new BotTurnExecutionError(
|
|
145
|
+
error instanceof Error ? error.message : "Bot turn failed",
|
|
146
|
+
appendedSessionEvents(previousEvents, events),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ExecuteBotTurnOptions {
|
|
151
|
+
command: BotTurnCommand;
|
|
152
|
+
previousEvents: readonly SessionEvent[];
|
|
153
|
+
/** The mounted Composition for the generation this Turn was pinned to. */
|
|
154
|
+
composition: ShellMountedComposition;
|
|
155
|
+
resume?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function executeBotTurn(
|
|
159
|
+
options: ExecuteBotTurnOptions,
|
|
160
|
+
): Promise<BotTurnCompletion> {
|
|
161
|
+
const { command, previousEvents, composition, resume } = options;
|
|
162
|
+
const runtime = composition.runtime;
|
|
163
|
+
try {
|
|
164
|
+
if (resume) runtime.agent.agent.resume();
|
|
165
|
+
else {
|
|
166
|
+
runtime.agent.agent.send({
|
|
167
|
+
text: command.text,
|
|
168
|
+
...(command.skills ? { skills: command.skills } : {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
await runtime.agent.agent.whenIdle();
|
|
172
|
+
return settleBotTurn(runtime.agent, command, previousEvents);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
turnExecutionError(error, previousEvents, [
|
|
175
|
+
...runtime.agent.agent.session.events,
|
|
176
|
+
]);
|
|
177
|
+
} finally {
|
|
178
|
+
await composition.dispose();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface ExecuteResidentBotTurnOptions {
|
|
183
|
+
botId: string;
|
|
184
|
+
command: BotTurnCommand;
|
|
185
|
+
previousEvents: readonly SessionEvent[];
|
|
186
|
+
persistSessionEvents: PersistSessionEvents;
|
|
187
|
+
beforeStart(): Promise<boolean>;
|
|
188
|
+
admitEffect(effect: AgentEffectAdmission): Promise<boolean>;
|
|
189
|
+
resume?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ResidentTurnRuntime {
|
|
193
|
+
execute(input: {
|
|
194
|
+
botId: string;
|
|
195
|
+
sessionId: string;
|
|
196
|
+
runId: string;
|
|
197
|
+
previousEvents: readonly SessionEvent[];
|
|
198
|
+
persistSessionEvents: PersistSessionEvents;
|
|
199
|
+
beforeStart(): Promise<boolean>;
|
|
200
|
+
admitEffect(effect: AgentEffectAdmission): Promise<boolean>;
|
|
201
|
+
resume?: boolean;
|
|
202
|
+
text: string;
|
|
203
|
+
skills?: SkillRefV1[];
|
|
204
|
+
turnType: TurnTypeV1;
|
|
205
|
+
subagentRole?: string;
|
|
206
|
+
}): Promise<AgentHandle>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Runs one Turn on the Bot Durable Object's resident Cordis root. The root
|
|
211
|
+
* outlives the Turn, so nothing is disposed here; the durable effect fence and
|
|
212
|
+
* the session persistence are the caller's.
|
|
213
|
+
*/
|
|
214
|
+
export async function executeResidentBotTurn(
|
|
215
|
+
runtime: ResidentTurnRuntime,
|
|
216
|
+
options: ExecuteResidentBotTurnOptions,
|
|
217
|
+
): Promise<BotTurnCompletion> {
|
|
218
|
+
const {
|
|
219
|
+
botId,
|
|
220
|
+
command,
|
|
221
|
+
previousEvents,
|
|
222
|
+
persistSessionEvents,
|
|
223
|
+
beforeStart,
|
|
224
|
+
admitEffect,
|
|
225
|
+
resume,
|
|
226
|
+
} = options;
|
|
227
|
+
let handle: AgentHandle | undefined;
|
|
228
|
+
try {
|
|
229
|
+
handle = await runtime.execute({
|
|
230
|
+
botId,
|
|
231
|
+
sessionId: command.sessionId,
|
|
232
|
+
runId: command.runId,
|
|
233
|
+
previousEvents,
|
|
234
|
+
persistSessionEvents,
|
|
235
|
+
beforeStart,
|
|
236
|
+
admitEffect,
|
|
237
|
+
resume,
|
|
238
|
+
text: command.text,
|
|
239
|
+
...(command.skills ? { skills: command.skills } : {}),
|
|
240
|
+
turnType: command.turnType ?? "chat",
|
|
241
|
+
...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
|
|
242
|
+
});
|
|
243
|
+
return settleBotTurn(handle, command, previousEvents);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
turnExecutionError(
|
|
246
|
+
error,
|
|
247
|
+
previousEvents,
|
|
248
|
+
handle ? [...handle.agent.session.events] : [...previousEvents],
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { FakeWorkspace } from "@frockbot/plugin-skills/testing";
|
|
3
|
+
import {
|
|
4
|
+
createBotPluginSkillsSource,
|
|
5
|
+
createBotSkillsHost,
|
|
6
|
+
} from "./backend-skills.ts";
|
|
7
|
+
|
|
8
|
+
const IDENTITY = { userId: "user-1", botId: "bot-1" };
|
|
9
|
+
const TURN = { runId: "run-9", turnId: "turn-4", sessionId: "user-1:bot-1" };
|
|
10
|
+
|
|
11
|
+
describe("the Bot Skills seam", () => {
|
|
12
|
+
test("mounts nothing when the Workspace file surface is unbound", () => {
|
|
13
|
+
expect(createBotSkillsHost(IDENTITY, TURN, {})).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("binds the Bot's own root and its Turn provenance when it is bound", () => {
|
|
17
|
+
const workspace = new FakeWorkspace();
|
|
18
|
+
const host = createBotSkillsHost(IDENTITY, TURN, {
|
|
19
|
+
WORKSPACE_FILES: workspace,
|
|
20
|
+
});
|
|
21
|
+
expect(host).toBeDefined();
|
|
22
|
+
expect(host?.owner).toEqual(IDENTITY);
|
|
23
|
+
expect(host?.writer).toEqual({
|
|
24
|
+
sessionId: "user-1:bot-1",
|
|
25
|
+
turnId: "turn-4",
|
|
26
|
+
runId: "run-9",
|
|
27
|
+
});
|
|
28
|
+
expect(host?.reads).toBe(workspace);
|
|
29
|
+
expect(host?.files).toBe(workspace);
|
|
30
|
+
// The seam reaches the Workspace and nothing else: no Computer is opened
|
|
31
|
+
// to build it, so a hibernated Computer changes none of this.
|
|
32
|
+
expect(workspace.calls).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("the plugin-borne Skill index seam", () => {
|
|
37
|
+
const READER = {
|
|
38
|
+
readEntry: (generation: string, catalogId: string) =>
|
|
39
|
+
Promise.resolve(
|
|
40
|
+
catalogId === "skillful"
|
|
41
|
+
? {
|
|
42
|
+
packageId: "skillful",
|
|
43
|
+
skills: [
|
|
44
|
+
{
|
|
45
|
+
name: "Roster check",
|
|
46
|
+
description: "Use this when rostering.",
|
|
47
|
+
body: `body at ${generation}`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
: undefined,
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test("mounts nothing when no Catalog is bound", () => {
|
|
56
|
+
expect(createBotPluginSkillsSource([], undefined)).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads only installed entries, at the generation each install pinned", async () => {
|
|
60
|
+
const source = createBotPluginSkillsSource(
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
packageId: "skillful",
|
|
64
|
+
state: "installed",
|
|
65
|
+
catalogId: "skillful",
|
|
66
|
+
catalogGeneration: "gen-3",
|
|
67
|
+
},
|
|
68
|
+
// A first-party install carries no Catalog identity, so there is no
|
|
69
|
+
// entry to index; a disabled Package's recipes are not read either.
|
|
70
|
+
{ packageId: "clock", state: "installed" },
|
|
71
|
+
{
|
|
72
|
+
packageId: "off",
|
|
73
|
+
state: "disabled",
|
|
74
|
+
catalogId: "off",
|
|
75
|
+
catalogGeneration: "gen-3",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
READER,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(await source?.read()).toEqual({
|
|
82
|
+
status: "ok",
|
|
83
|
+
packages: [
|
|
84
|
+
{
|
|
85
|
+
packageId: "skillful",
|
|
86
|
+
catalogId: "skillful",
|
|
87
|
+
generation: "gen-3",
|
|
88
|
+
skills: [
|
|
89
|
+
{
|
|
90
|
+
name: "Roster check",
|
|
91
|
+
description: "Use this when rostering.",
|
|
92
|
+
body: "body at gen-3",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an uninstalled entry leaves nothing to index", async () => {
|
|
101
|
+
const source = createBotPluginSkillsSource([], READER);
|
|
102
|
+
expect(await source?.read()).toEqual({ status: "ok", packages: [] });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a Catalog read that throws is an unavailable index, not a failed Turn", async () => {
|
|
106
|
+
const source = createBotPluginSkillsSource(
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
packageId: "skillful",
|
|
110
|
+
state: "installed",
|
|
111
|
+
catalogId: "skillful",
|
|
112
|
+
catalogGeneration: "gen-3",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
{
|
|
116
|
+
readEntry: () => Promise.reject(new Error("R2 is down")),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const outcome = await source?.read();
|
|
121
|
+
expect(outcome?.status).toBe("unavailable");
|
|
122
|
+
expect(outcome?.status === "unavailable" ? outcome.reason : "").toContain(
|
|
123
|
+
"R2 is down",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The Bot Durable Object's half of the Skills seam.
|
|
2
|
+
//
|
|
3
|
+
// The Skills Package reads a Bot's instruction root through the
|
|
4
|
+
// kernel-declared `WorkspaceReadsV1` and writes through `WorkspaceFilesV1`.
|
|
5
|
+
// This module decides, for one admitted Turn, whether such a surface exists
|
|
6
|
+
// and what provenance a write records. It implements neither interface.
|
|
7
|
+
//
|
|
8
|
+
// HIBERNATION. "The Agent loop, Memory, Skills, Package composition, and
|
|
9
|
+
// Routines function correctly while the Computer is hibernated and do not wake
|
|
10
|
+
// it." Nothing here reaches the Computer registry, a Computer provider, or a
|
|
11
|
+
// Sprite. The Workspace surface handed to the Skills Package is a binding on
|
|
12
|
+
// the Durable Object's environment, and the durable-root sync of ADR 0013
|
|
13
|
+
// backs it from object storage; whether a Computer host happens to be running
|
|
14
|
+
// changes nothing above this line.
|
|
15
|
+
//
|
|
16
|
+
// SEAM. `WORKSPACE_FILES` is bound in production by
|
|
17
|
+
// `apps/cloudflare/src/workspace.ts`: `WorkspaceFilesV1` over object storage,
|
|
18
|
+
// with every generation recorded in this Bot's Durable Object (Step 3a of
|
|
19
|
+
// `docs/plans/slice-2.md`). A host that binds nothing — a test, a shell with no
|
|
20
|
+
// bucket — still gets `undefined` here, and the Skills Package is then not
|
|
21
|
+
// mounted at all: a Turn with no readable instruction root loads no
|
|
22
|
+
// instructions, visibly, rather than inventing a second store to read them
|
|
23
|
+
// from.
|
|
24
|
+
import type {
|
|
25
|
+
WorkspaceFilesV1,
|
|
26
|
+
WorkspaceReadsV1,
|
|
27
|
+
} from "@frockbot/kernel-contracts";
|
|
28
|
+
import type { SkillsRuntimeHostV1 } from "@frockbot/plugin-skills/agent";
|
|
29
|
+
import type {
|
|
30
|
+
PluginSkillPackageV1,
|
|
31
|
+
PluginSkillsSourceV1,
|
|
32
|
+
} from "@frockbot/plugin-skills/plugin-index";
|
|
33
|
+
|
|
34
|
+
/** The Bot and User whose Skills a Turn may load. */
|
|
35
|
+
export interface BotSkillsIdentity {
|
|
36
|
+
userId: string;
|
|
37
|
+
botId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The run, Turn, and Session a Bot-authored Skill records as its provenance. */
|
|
41
|
+
export interface BotSkillsTurn {
|
|
42
|
+
runId: string;
|
|
43
|
+
turnId: string;
|
|
44
|
+
sessionId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The narrow slice of the Durable Object environment this module reads. Named
|
|
49
|
+
* as its own type so the binding's absence is a typed state, not a cast.
|
|
50
|
+
*/
|
|
51
|
+
export interface BotSkillsEnv {
|
|
52
|
+
WORKSPACE_FILES?: WorkspaceFilesV1;
|
|
53
|
+
/**
|
|
54
|
+
* The Package Catalog reader, constructed onto the Bot Durable Object's
|
|
55
|
+
* environment the same way `WORKSPACE_FILES` is. Absent for a deployment
|
|
56
|
+
* with no Catalog, and the Turn then carries no plugin-borne Skills.
|
|
57
|
+
*/
|
|
58
|
+
PACKAGE_CATALOG_ENTRIES?: BotSkillCatalogReaderV1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Skills seam one admitted Turn runs under, or `undefined` when the Bot's
|
|
63
|
+
* Workspace file surface is unavailable.
|
|
64
|
+
*/
|
|
65
|
+
export function createBotSkillsHost(
|
|
66
|
+
identity: BotSkillsIdentity,
|
|
67
|
+
turn: BotSkillsTurn,
|
|
68
|
+
env: object,
|
|
69
|
+
pluginSkills?: PluginSkillsSourceV1,
|
|
70
|
+
): SkillsRuntimeHostV1 | undefined {
|
|
71
|
+
// SAFETY: the Workspace file surface is constructed onto the Durable Object
|
|
72
|
+
// environment rather than declared in the generated `Env`, because it is not
|
|
73
|
+
// a Worker binding. Absence is a supported state, not an error.
|
|
74
|
+
const files = (env as BotSkillsEnv).WORKSPACE_FILES;
|
|
75
|
+
if (!files) return undefined;
|
|
76
|
+
return {
|
|
77
|
+
owner: { userId: identity.userId, botId: identity.botId },
|
|
78
|
+
reads: files,
|
|
79
|
+
files,
|
|
80
|
+
// A Bot writes a Skill only inside a Turn whose run, Turn and Session its
|
|
81
|
+
// provenance names — the same rule Package authoring follows.
|
|
82
|
+
writer: {
|
|
83
|
+
sessionId: turn.sessionId,
|
|
84
|
+
turnId: turn.turnId,
|
|
85
|
+
runId: turn.runId,
|
|
86
|
+
},
|
|
87
|
+
...(pluginSkills ? { pluginSkills } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The read-only half of the same seam, for a question asked outside a Turn.
|
|
93
|
+
*
|
|
94
|
+
* The composer's `/` and `@` popover needs the Bot's Skill catalog before any
|
|
95
|
+
* Turn exists, and reading a catalog needs no provenance: there is nothing to
|
|
96
|
+
* attribute. So this returns reads and no writer at all — a caller holding it
|
|
97
|
+
* can enumerate an instruction root and can write nothing.
|
|
98
|
+
*/
|
|
99
|
+
export function createBotSkillsReads(
|
|
100
|
+
env: object,
|
|
101
|
+
): WorkspaceReadsV1 | undefined {
|
|
102
|
+
return (env as BotSkillsEnv).WORKSPACE_FILES;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The Catalog reader the plugin-borne Skill index needs, named structurally.
|
|
107
|
+
*
|
|
108
|
+
* Structural rather than imported so this module keeps naming no Cloudflare
|
|
109
|
+
* type: `R2PackageCatalog` in `apps/cloudflare/src/package-catalog.ts` already
|
|
110
|
+
* satisfies it, and a test satisfies it with an object literal. The reader is
|
|
111
|
+
* asked for one entry at one *pinned* generation, never for the live pointer:
|
|
112
|
+
* a Turn reads exactly the immutable generation its User is pinned to.
|
|
113
|
+
*/
|
|
114
|
+
export interface BotSkillCatalogReaderV1 {
|
|
115
|
+
readEntry(
|
|
116
|
+
generation: string,
|
|
117
|
+
catalogId: string,
|
|
118
|
+
): Promise<
|
|
119
|
+
| {
|
|
120
|
+
packageId: string;
|
|
121
|
+
skills: readonly {
|
|
122
|
+
name: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
body?: string;
|
|
125
|
+
}[];
|
|
126
|
+
}
|
|
127
|
+
| undefined
|
|
128
|
+
>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One installed Package, as the User's settings record it. */
|
|
132
|
+
export interface BotInstalledPackageV1 {
|
|
133
|
+
packageId: string;
|
|
134
|
+
state: "installed" | "disabled" | "failed";
|
|
135
|
+
catalogId?: string;
|
|
136
|
+
catalogGeneration?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The plugin-borne Skill index for one Turn.
|
|
141
|
+
*
|
|
142
|
+
* Reads only the entries the User has actually installed, at the generation
|
|
143
|
+
* each install recorded. That is what makes an uninstall remove a Skill: the
|
|
144
|
+
* row is gone from the User's settings, so the next Turn's index does not
|
|
145
|
+
* name it, and no copy of the body exists anywhere to go stale. A disabled
|
|
146
|
+
* Package contributes nothing either — a Bot that may not run a Package's
|
|
147
|
+
* tools should not be reading its recipes.
|
|
148
|
+
*/
|
|
149
|
+
export function createBotPluginSkillsSource(
|
|
150
|
+
installed: readonly BotInstalledPackageV1[],
|
|
151
|
+
reader: BotSkillCatalogReaderV1 | undefined,
|
|
152
|
+
): PluginSkillsSourceV1 | undefined {
|
|
153
|
+
if (!reader) return undefined;
|
|
154
|
+
const rows = installed.filter(
|
|
155
|
+
(row) =>
|
|
156
|
+
row.state === "installed" &&
|
|
157
|
+
row.catalogId !== undefined &&
|
|
158
|
+
row.catalogGeneration !== undefined,
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
read: async () => {
|
|
162
|
+
const packages: PluginSkillPackageV1[] = [];
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
const catalogId = row.catalogId as string;
|
|
165
|
+
const generation = row.catalogGeneration as string;
|
|
166
|
+
try {
|
|
167
|
+
const entry = await reader.readEntry(generation, catalogId);
|
|
168
|
+
if (!entry) continue;
|
|
169
|
+
if (entry.skills.length === 0) continue;
|
|
170
|
+
packages.push({
|
|
171
|
+
packageId: entry.packageId,
|
|
172
|
+
catalogId,
|
|
173
|
+
generation,
|
|
174
|
+
skills: entry.skills,
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// A Catalog read that fails is an unavailable index, not a failed
|
|
178
|
+
// Turn: the loader records it as a refusal in `skill/injected` and
|
|
179
|
+
// the Bot runs with the Skills it could read.
|
|
180
|
+
return {
|
|
181
|
+
status: "unavailable",
|
|
182
|
+
reason: `Catalog entry "${catalogId}" at generation "${generation}" could not be read: ${
|
|
183
|
+
error instanceof Error ? error.message : String(error)
|
|
184
|
+
}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { status: "ok", packages };
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The Catalog reader bound on this host, or `undefined` when there is none. */
|
|
194
|
+
export function createBotSkillCatalogReader(
|
|
195
|
+
env: object,
|
|
196
|
+
): BotSkillCatalogReaderV1 | undefined {
|
|
197
|
+
return (env as BotSkillsEnv).PACKAGE_CATALOG_ENTRIES;
|
|
198
|
+
}
|