@frockbot/plugin-shell 0.1.3 → 0.2.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 +4 -0
- package/package.json +30 -28
- package/src/agent.ts +10 -13
- package/src/backend-authoring.test.ts +356 -2
- package/src/backend-authoring.ts +585 -20
- package/src/backend-composition-input.test.ts +65 -0
- package/src/backend-composition-input.ts +58 -0
- package/src/backend-composition.ts +23 -5
- package/src/backend-configuration.test.ts +549 -1468
- package/src/backend-contracts.test.ts +28 -0
- package/src/backend-contracts.ts +3 -1
- package/src/backend-execution.ts +0 -4
- package/src/backend-iframe-ui.test.ts +131 -0
- package/src/backend-image.test.ts +8 -8
- package/src/backend-image.ts +13 -13
- package/src/backend-isolate.test.ts +230 -89
- package/src/backend-isolate.ts +103 -200
- package/src/backend-package-catalog.test.ts +451 -0
- package/src/backend-package-catalog.ts +923 -0
- package/src/backend-recovery-integration.test.ts +17 -67
- package/src/backend-routines.ts +1 -1
- package/src/backend-runner-iframe.test.ts +74 -0
- package/src/backend-runner.ts +192 -0
- package/src/backend.ts +1198 -1781
- package/src/client/FrockBotApp.vue +44 -73
- package/src/client/PackageIframeHost.vue +218 -0
- package/src/client/PackageIframeSettings.vue +52 -0
- package/src/client/SendPayloadView.vue +0 -60
- package/src/client/index.test.ts +439 -380
- package/src/client/index.ts +163 -257
- package/src/client/model-presentation.test.ts +21 -8
- package/src/client/model-presentation.ts +14 -7
- package/src/client/package-iframe-host-message.test.ts +41 -0
- package/src/client/package-iframe-host-message.ts +27 -0
- package/src/client/styles.css +0 -27
- package/src/composition-views.ts +54 -0
- package/src/settings-links.test.ts +2 -10
- package/src/settings-links.ts +1 -13
- package/src/shared.ts +10 -13
- package/src/backend-assignment.test.ts +0 -161
- package/src/backend-assignment.ts +0 -274
|
@@ -105,6 +105,11 @@ describe("Bot recovery", () => {
|
|
|
105
105
|
revision: 1,
|
|
106
106
|
profile: { name: "User" },
|
|
107
107
|
packages: [
|
|
108
|
+
{
|
|
109
|
+
packageId: "custom-models",
|
|
110
|
+
version: "0.0.1",
|
|
111
|
+
state: "installed",
|
|
112
|
+
},
|
|
108
113
|
{
|
|
109
114
|
packageId: "provider-ollama-cloud",
|
|
110
115
|
version: "0.0.1",
|
|
@@ -140,6 +145,10 @@ describe("Bot recovery", () => {
|
|
|
140
145
|
},
|
|
141
146
|
},
|
|
142
147
|
],
|
|
148
|
+
platformModel: {
|
|
149
|
+
connectionId: "ollama-1",
|
|
150
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
151
|
+
},
|
|
143
152
|
};
|
|
144
153
|
const leasedRequests: Array<Record<string, unknown>> = [];
|
|
145
154
|
const settledEffects: string[] = [];
|
|
@@ -148,35 +157,6 @@ describe("Bot recovery", () => {
|
|
|
148
157
|
readConfiguration: () => Promise.resolve(structuredClone(userSettings)),
|
|
149
158
|
listBots: () =>
|
|
150
159
|
Promise.resolve({ schemaVersion: 1 as const, revision: 0, bots: [] }),
|
|
151
|
-
getConnection: () =>
|
|
152
|
-
Promise.resolve(structuredClone(userSettings.connections[0])),
|
|
153
|
-
executeConnectionDependency: (request: { action: string }) => {
|
|
154
|
-
if (request.action === "claim") {
|
|
155
|
-
return Promise.resolve({
|
|
156
|
-
schemaVersion: 1 as const,
|
|
157
|
-
status: "claimed" as const,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
if (request.action === "acknowledge") {
|
|
161
|
-
return Promise.resolve({
|
|
162
|
-
schemaVersion: 1 as const,
|
|
163
|
-
status: "acknowledged" as const,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
if (request.action === "read") {
|
|
167
|
-
return Promise.resolve({
|
|
168
|
-
schemaVersion: 1 as const,
|
|
169
|
-
status: "acknowledged" as const,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
return Promise.resolve({
|
|
173
|
-
schemaVersion: 1 as const,
|
|
174
|
-
status: "released" as const,
|
|
175
|
-
});
|
|
176
|
-
},
|
|
177
|
-
claimConnectionDependency: () => Promise.resolve(true),
|
|
178
|
-
acknowledgeConnectionDependency: () => Promise.resolve(true),
|
|
179
|
-
compensateConnectionDependency: () => Promise.resolve(true),
|
|
180
160
|
leaseModelCredential: (input: unknown) => {
|
|
181
161
|
leasedRequests.push(input as Record<string, unknown>);
|
|
182
162
|
const request = input as { effectId: string };
|
|
@@ -241,32 +221,8 @@ describe("Bot recovery", () => {
|
|
|
241
221
|
const configured = host();
|
|
242
222
|
await configured.materializeSettings(
|
|
243
223
|
{ userId: "user-1", botId: "primary" },
|
|
244
|
-
{
|
|
245
|
-
name: "Ollama Bot",
|
|
246
|
-
model: {
|
|
247
|
-
connectionId: "ollama-1",
|
|
248
|
-
providerModelId: "glm-5.3-flash:cloud",
|
|
249
|
-
},
|
|
250
|
-
},
|
|
224
|
+
{ name: "Ollama Bot" },
|
|
251
225
|
);
|
|
252
|
-
await configured.executeConfiguration({
|
|
253
|
-
schemaVersion: 1,
|
|
254
|
-
userId: "user-1",
|
|
255
|
-
botId: "primary",
|
|
256
|
-
command: {
|
|
257
|
-
schemaVersion: 1,
|
|
258
|
-
type: "bot/assign-capability",
|
|
259
|
-
commandId: "assign-ollama-model",
|
|
260
|
-
botId: "primary",
|
|
261
|
-
expectedRevision: 0,
|
|
262
|
-
assignment: {
|
|
263
|
-
assignmentId: "ollama-model",
|
|
264
|
-
packageId: "provider-ollama-cloud",
|
|
265
|
-
capabilityId: "ollama-cloud-models",
|
|
266
|
-
connectionId: "ollama-1",
|
|
267
|
-
},
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
226
|
const first = await host().run({
|
|
271
227
|
userId: "user-1",
|
|
272
228
|
botId: "primary",
|
|
@@ -1353,6 +1309,11 @@ describe("Bot recovery", () => {
|
|
|
1353
1309
|
revision: 1,
|
|
1354
1310
|
profile: { name: "User" },
|
|
1355
1311
|
packages: [
|
|
1312
|
+
{
|
|
1313
|
+
packageId: "custom-models",
|
|
1314
|
+
version: "0.0.1",
|
|
1315
|
+
state: "installed",
|
|
1316
|
+
},
|
|
1356
1317
|
{
|
|
1357
1318
|
packageId: "provider-ollama-cloud",
|
|
1358
1319
|
version: "0.0.1",
|
|
@@ -1371,23 +1332,12 @@ describe("Bot recovery", () => {
|
|
|
1371
1332
|
safeMetadata: {},
|
|
1372
1333
|
},
|
|
1373
1334
|
],
|
|
1374
|
-
|
|
1375
|
-
const settings = {
|
|
1376
|
-
...initializeBotSettingsV1("primary"),
|
|
1377
|
-
model: {
|
|
1335
|
+
platformModel: {
|
|
1378
1336
|
connectionId: "ollama-race",
|
|
1379
1337
|
providerModelId: "model:cloud",
|
|
1380
1338
|
},
|
|
1381
|
-
assignments: [
|
|
1382
|
-
{
|
|
1383
|
-
assignmentId: "model-race",
|
|
1384
|
-
packageId: "provider-ollama-cloud",
|
|
1385
|
-
capabilityId: "ollama-cloud-models",
|
|
1386
|
-
connectionId: "ollama-race",
|
|
1387
|
-
state: "enabled" as const,
|
|
1388
|
-
},
|
|
1389
|
-
],
|
|
1390
1339
|
};
|
|
1340
|
+
const settings = initializeBotSettingsV1("primary");
|
|
1391
1341
|
await storage.put("bot-configuration", settings);
|
|
1392
1342
|
const contribution = createShellBotBackendContribution({
|
|
1393
1343
|
state: { storage } as unknown as DurableObjectState,
|
package/src/backend-routines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Bot Durable Object's half of the Routines seam.
|
|
2
2
|
//
|
|
3
3
|
// "The Bot's Durable Object is the authority for everything Bot-scoped: …
|
|
4
|
-
// durable scheduling, Routines,
|
|
4
|
+
// durable scheduling, Routines, and Composition." The Routines Package holds the
|
|
5
5
|
// records, the codecs, the command semantics and the scheduler; this module
|
|
6
6
|
// supplies the two things the Package cannot own — the Durable Object's storage,
|
|
7
7
|
// and the one call that admits a Turn.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context } from "cordis";
|
|
3
|
+
import { SessionStore } from "@frockbot/kernel-contracts";
|
|
4
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
5
|
+
import type { ShellMountedComposition } from "./backend-composition.js";
|
|
6
|
+
import { executeDirectToolTurn } from "./backend-runner.js";
|
|
7
|
+
|
|
8
|
+
describe("Package iframe direct tool Turn", () => {
|
|
9
|
+
test("journals intent before execution and returns the result in the ordinary Session log", async () => {
|
|
10
|
+
const root = new Context();
|
|
11
|
+
await root.plugin(SessionStore);
|
|
12
|
+
await root.plugin(ToolRegistry);
|
|
13
|
+
const session = root.sessions.create("user:bot");
|
|
14
|
+
let calls = 0;
|
|
15
|
+
root.tools.register({
|
|
16
|
+
name: "weather_lookup",
|
|
17
|
+
description: "Weather",
|
|
18
|
+
inputSchema: {},
|
|
19
|
+
idempotent: true,
|
|
20
|
+
execute: async () => {
|
|
21
|
+
calls += 1;
|
|
22
|
+
expect(session.events.at(-1)?.type).toBe("tool/call");
|
|
23
|
+
return { content: '{"temperature":21}', isError: false };
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const previous = [...session.events];
|
|
27
|
+
const generation = {
|
|
28
|
+
schemaVersion: 1 as const,
|
|
29
|
+
generationId: "generation-1",
|
|
30
|
+
artifactSetHash: "a".repeat(64),
|
|
31
|
+
createdAt: "2026-09-02T00:00:00.000Z",
|
|
32
|
+
origin: { kind: "bootstrap" as const },
|
|
33
|
+
members: [],
|
|
34
|
+
status: "active" as const,
|
|
35
|
+
};
|
|
36
|
+
const composition = {
|
|
37
|
+
root,
|
|
38
|
+
generation,
|
|
39
|
+
runtime: {
|
|
40
|
+
root,
|
|
41
|
+
agent: { agent: { session, botId: "bot", id: "bot" } },
|
|
42
|
+
},
|
|
43
|
+
verify: () => Promise.resolve(),
|
|
44
|
+
dispose: () => root.fiber.dispose(),
|
|
45
|
+
} as unknown as ShellMountedComposition;
|
|
46
|
+
|
|
47
|
+
const result = await executeDirectToolTurn({
|
|
48
|
+
command: {
|
|
49
|
+
runId: "command-1",
|
|
50
|
+
sessionId: "user:bot",
|
|
51
|
+
acceptedAt: "2026-09-02T00:00:00.000Z",
|
|
52
|
+
text: "Weather · weather_lookup",
|
|
53
|
+
directTool: {
|
|
54
|
+
generationId: "generation-1",
|
|
55
|
+
packageId: "weather-page",
|
|
56
|
+
name: "weather_lookup",
|
|
57
|
+
input: { city: "Sydney" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
previousEvents: previous,
|
|
61
|
+
composition,
|
|
62
|
+
admitEffect: () => Promise.resolve(true),
|
|
63
|
+
signal: new AbortController().signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(calls).toBe(1);
|
|
67
|
+
expect(result.events.map((event) => event.type)).toEqual(
|
|
68
|
+
expect.arrayContaining(["tool/call", "tool/result", "turn/end"]),
|
|
69
|
+
);
|
|
70
|
+
expect(
|
|
71
|
+
result.events.find((event) => event.type === "tool/result"),
|
|
72
|
+
).toMatchObject({ name: "weather_lookup", content: '{"temperature":21}' });
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/backend-runner.ts
CHANGED
|
@@ -155,6 +155,198 @@ export interface ExecuteBotTurnOptions {
|
|
|
155
155
|
resume?: boolean;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
export interface ExecuteDirectToolTurnOptions {
|
|
159
|
+
command: BotTurnCommand & {
|
|
160
|
+
directTool: NonNullable<BotTurnCommand["directTool"]>;
|
|
161
|
+
};
|
|
162
|
+
previousEvents: readonly SessionEvent[];
|
|
163
|
+
composition: ShellMountedComposition;
|
|
164
|
+
admitEffect(effect: AgentEffectAdmission): Promise<boolean>;
|
|
165
|
+
signal: AbortSignal;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Runs a Package-page tool as one ordinary durable Turn, without a model call.
|
|
170
|
+
* The same Session journal and effect fence make retry/recovery identical to a
|
|
171
|
+
* model-selected tool occurrence.
|
|
172
|
+
*/
|
|
173
|
+
export async function executeDirectToolTurn(
|
|
174
|
+
options: ExecuteDirectToolTurnOptions,
|
|
175
|
+
): Promise<BotTurnCompletion> {
|
|
176
|
+
const { command, composition, previousEvents, admitEffect, signal } = options;
|
|
177
|
+
const session = composition.runtime.agent.agent.session;
|
|
178
|
+
const call = {
|
|
179
|
+
id: command.runId,
|
|
180
|
+
name: command.directTool.name,
|
|
181
|
+
input: command.directTool.input,
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
let turnStart = [...session.events].findLast(
|
|
185
|
+
(event) =>
|
|
186
|
+
event.type === "turn/start" &&
|
|
187
|
+
!session.events.some(
|
|
188
|
+
(candidate) =>
|
|
189
|
+
candidate.type === "turn/end" && candidate.turn === event.turn,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
if (!turnStart || turnStart.type !== "turn/start") {
|
|
193
|
+
const turn = session.nextTurn();
|
|
194
|
+
const messageId = `iframe:${command.runId}`;
|
|
195
|
+
session.appendBatch([
|
|
196
|
+
{ type: "input/queued", messageId, text: command.text },
|
|
197
|
+
{ type: "turn/start", turn },
|
|
198
|
+
{
|
|
199
|
+
type: "composition/pinned",
|
|
200
|
+
turn,
|
|
201
|
+
generationId: composition.generation.generationId,
|
|
202
|
+
artifactSetHash: composition.generation.artifactSetHash,
|
|
203
|
+
},
|
|
204
|
+
{ type: "turn/admission", turn, turnType: "chat" },
|
|
205
|
+
{ type: "input/admitted", messageId, turn },
|
|
206
|
+
{ type: "step/start", turn, step: 1 },
|
|
207
|
+
{ type: "user/message", turn, step: 1, messageId, text: command.text },
|
|
208
|
+
{
|
|
209
|
+
type: "assistant/message",
|
|
210
|
+
turn,
|
|
211
|
+
step: 1,
|
|
212
|
+
requestId: `iframe:${command.runId}`,
|
|
213
|
+
text: "",
|
|
214
|
+
toolCalls: [call],
|
|
215
|
+
},
|
|
216
|
+
]);
|
|
217
|
+
await session.flush();
|
|
218
|
+
turnStart = session.events.findLast(
|
|
219
|
+
(event) => event.type === "turn/start" && event.turn === turn,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!turnStart || turnStart.type !== "turn/start") {
|
|
223
|
+
throw new Error("Package UI tool Turn has no durable start");
|
|
224
|
+
}
|
|
225
|
+
const turn = turnStart.turn;
|
|
226
|
+
const occurrenceId = `tool:${turn}:1:0`;
|
|
227
|
+
const journal = validateToolOccurrenceJournal(session.events);
|
|
228
|
+
const existing = journal.get(occurrenceId);
|
|
229
|
+
if (!existing) throw new Error("Package UI tool occurrence is unavailable");
|
|
230
|
+
|
|
231
|
+
if (!existing.result) {
|
|
232
|
+
const context = {
|
|
233
|
+
botId: composition.runtime.agent.agent.botId,
|
|
234
|
+
agentId: composition.runtime.agent.agent.id,
|
|
235
|
+
sessionId: command.sessionId,
|
|
236
|
+
compositionGenerationId: composition.generation.generationId,
|
|
237
|
+
effectId: occurrenceId,
|
|
238
|
+
toolCall: call,
|
|
239
|
+
turnType: "chat" as const,
|
|
240
|
+
signal,
|
|
241
|
+
};
|
|
242
|
+
const preparation = await composition.root.tools.prepare(call, context);
|
|
243
|
+
let result;
|
|
244
|
+
if (existing.intent) {
|
|
245
|
+
if (preparation.kind !== "ready") {
|
|
246
|
+
throw new BotTurnReconciliationRequiredError(
|
|
247
|
+
`Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
|
|
248
|
+
appendedSessionEvents(previousEvents, session.events),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const reconciled = await composition.root.tools.reconcilePrepared(
|
|
252
|
+
preparation,
|
|
253
|
+
context,
|
|
254
|
+
);
|
|
255
|
+
if (reconciled.status === "unavailable") {
|
|
256
|
+
throw new BotTurnReconciliationRequiredError(
|
|
257
|
+
reconciled.reason,
|
|
258
|
+
appendedSessionEvents(previousEvents, session.events),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
result = reconciled.result;
|
|
262
|
+
} else {
|
|
263
|
+
session.append({
|
|
264
|
+
type: "tool/call",
|
|
265
|
+
turn,
|
|
266
|
+
step: 1,
|
|
267
|
+
occurrenceId,
|
|
268
|
+
name: call.name,
|
|
269
|
+
input: call.input,
|
|
270
|
+
});
|
|
271
|
+
await session.flush();
|
|
272
|
+
if (preparation.kind === "denied") {
|
|
273
|
+
result = preparation.result;
|
|
274
|
+
} else {
|
|
275
|
+
if (!(await admitEffect({ kind: "tool", effectId: occurrenceId }))) {
|
|
276
|
+
session.appendBatch([
|
|
277
|
+
{
|
|
278
|
+
type: "tool/result",
|
|
279
|
+
turn,
|
|
280
|
+
step: 1,
|
|
281
|
+
occurrenceId,
|
|
282
|
+
name: call.name,
|
|
283
|
+
content: "Cancelled before tool execution started.",
|
|
284
|
+
isError: true,
|
|
285
|
+
status: "interrupted",
|
|
286
|
+
},
|
|
287
|
+
{ type: "step/end", turn, step: 1, outcome: "cancelled" },
|
|
288
|
+
{ type: "turn/end", turn, outcome: "cancelled" },
|
|
289
|
+
]);
|
|
290
|
+
await session.flush();
|
|
291
|
+
throw new Error("Package UI tool effect was fenced by Stop");
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
result = await composition.root.tools.executePrepared(
|
|
295
|
+
preparation,
|
|
296
|
+
context,
|
|
297
|
+
);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (signal.aborted || !preparation.idempotent) {
|
|
300
|
+
throw new BotTurnReconciliationRequiredError(
|
|
301
|
+
`Tool effect "${occurrenceId}" outcome is uncertain`,
|
|
302
|
+
appendedSessionEvents(previousEvents, session.events),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
result = {
|
|
306
|
+
content:
|
|
307
|
+
error instanceof Error
|
|
308
|
+
? error.message
|
|
309
|
+
: "Tool execution failed",
|
|
310
|
+
isError: true,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
session.append({
|
|
316
|
+
type: "tool/result",
|
|
317
|
+
turn,
|
|
318
|
+
step: 1,
|
|
319
|
+
occurrenceId,
|
|
320
|
+
name: call.name,
|
|
321
|
+
content: result.content,
|
|
322
|
+
isError: result.isError,
|
|
323
|
+
status: "completed",
|
|
324
|
+
...(result.attachments?.length
|
|
325
|
+
? { attachments: result.attachments }
|
|
326
|
+
: {}),
|
|
327
|
+
});
|
|
328
|
+
await session.flush();
|
|
329
|
+
}
|
|
330
|
+
const hasTerminal = session.events.some(
|
|
331
|
+
(event) => event.type === "turn/end" && event.turn === turn,
|
|
332
|
+
);
|
|
333
|
+
if (!hasTerminal) {
|
|
334
|
+
session.appendBatch([
|
|
335
|
+
{ type: "step/end", turn, step: 1, outcome: "completed" },
|
|
336
|
+
{ type: "turn/end", turn, outcome: "completed" },
|
|
337
|
+
]);
|
|
338
|
+
await session.flush();
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
runId: command.runId,
|
|
342
|
+
text: "",
|
|
343
|
+
events: appendedSessionEvents(previousEvents, session.events),
|
|
344
|
+
};
|
|
345
|
+
} finally {
|
|
346
|
+
await composition.dispose();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
158
350
|
export async function executeBotTurn(
|
|
159
351
|
options: ExecuteBotTurnOptions,
|
|
160
352
|
): Promise<BotTurnCompletion> {
|