@frockbot/plugin-shell 0.1.4 → 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 +19 -1
- package/src/backend-configuration.test.ts +166 -0
- package/src/backend-contracts.test.ts +28 -0
- package/src/backend-contracts.ts +3 -1
- package/src/backend-iframe-ui.test.ts +131 -0
- package/src/backend-isolate.test.ts +198 -203
- package/src/backend-isolate.ts +96 -231
- package/src/backend-package-catalog.test.ts +451 -0
- package/src/backend-package-catalog.ts +923 -0
- package/src/backend-runner-iframe.test.ts +74 -0
- package/src/backend-runner.ts +192 -0
- package/src/backend.ts +947 -206
- package/src/client/FrockBotApp.vue +38 -0
- 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 +119 -0
- package/src/client/index.ts +78 -1
- package/src/client/package-iframe-host-message.test.ts +41 -0
- package/src/client/package-iframe-host-message.ts +27 -0
- package/src/composition-views.ts +54 -0
- package/src/shared.ts +10 -0
|
@@ -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> {
|