@frockbot/plugin-shell 0.3.20 → 0.3.22
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 +32 -32
- package/src/agent.test.ts +32 -0
- package/src/agent.ts +27 -0
- package/src/backend-applets.test.ts +38 -0
- package/src/backend-applets.ts +32 -2
- package/src/backend.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -52,42 +52,42 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
55
|
-
"@frockbot/agent-runtime": "0.3.
|
|
56
|
-
"@frockbot/application-foundation": "0.3.
|
|
57
|
-
"@frockbot/catalog-core": "0.3.
|
|
58
|
-
"@frockbot/client-core": "0.3.
|
|
59
|
-
"@frockbot/client-ui": "0.3.
|
|
60
|
-
"@frockbot/computer-core": "0.3.
|
|
61
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
62
|
-
"@frockbot/configuration-core": "0.3.
|
|
63
|
-
"@frockbot/connection-core": "0.3.
|
|
64
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
65
|
-
"@frockbot/kernel-composition": "0.3.
|
|
66
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
67
|
-
"@frockbot/kernel-do": "0.3.
|
|
68
|
-
"@frockbot/machine-protocol": "0.3.
|
|
69
|
-
"@frockbot/plugin-applets": "0.3.
|
|
70
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
71
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
72
|
-
"@frockbot/plugin-computer": "0.3.
|
|
73
|
-
"@frockbot/plugin-flock": "0.3.
|
|
74
|
-
"@frockbot/plugin-image": "0.3.
|
|
75
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
76
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
77
|
-
"@frockbot/plugin-memory": "0.3.
|
|
78
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
79
|
-
"@frockbot/plugin-routines": "0.3.
|
|
80
|
-
"@frockbot/plugin-skills": "0.3.
|
|
81
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
82
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
83
|
-
"@frockbot/protocol": "0.3.
|
|
55
|
+
"@frockbot/agent-runtime": "0.3.22",
|
|
56
|
+
"@frockbot/application-foundation": "0.3.22",
|
|
57
|
+
"@frockbot/catalog-core": "0.3.22",
|
|
58
|
+
"@frockbot/client-core": "0.3.22",
|
|
59
|
+
"@frockbot/client-ui": "0.3.22",
|
|
60
|
+
"@frockbot/computer-core": "0.3.22",
|
|
61
|
+
"@frockbot/computer-host-protocol": "0.3.22",
|
|
62
|
+
"@frockbot/configuration-core": "0.3.22",
|
|
63
|
+
"@frockbot/connection-core": "0.3.22",
|
|
64
|
+
"@frockbot/kernel-agent-loop": "0.3.22",
|
|
65
|
+
"@frockbot/kernel-composition": "0.3.22",
|
|
66
|
+
"@frockbot/kernel-contracts": "0.3.22",
|
|
67
|
+
"@frockbot/kernel-do": "0.3.22",
|
|
68
|
+
"@frockbot/machine-protocol": "0.3.22",
|
|
69
|
+
"@frockbot/plugin-applets": "0.3.22",
|
|
70
|
+
"@frockbot/plugin-authoring": "0.3.22",
|
|
71
|
+
"@frockbot/plugin-bot-template": "0.3.22",
|
|
72
|
+
"@frockbot/plugin-computer": "0.3.22",
|
|
73
|
+
"@frockbot/plugin-flock": "0.3.22",
|
|
74
|
+
"@frockbot/plugin-image": "0.3.22",
|
|
75
|
+
"@frockbot/plugin-machine-messages": "0.3.22",
|
|
76
|
+
"@frockbot/plugin-mcp": "0.3.22",
|
|
77
|
+
"@frockbot/plugin-memory": "0.3.22",
|
|
78
|
+
"@frockbot/plugin-package-catalog": "0.3.22",
|
|
79
|
+
"@frockbot/plugin-routines": "0.3.22",
|
|
80
|
+
"@frockbot/plugin-skills": "0.3.22",
|
|
81
|
+
"@frockbot/plugin-subagents": "0.3.22",
|
|
82
|
+
"@frockbot/plugin-user-machine": "0.3.22",
|
|
83
|
+
"@frockbot/protocol": "0.3.22",
|
|
84
84
|
"cordis": "4.0.0-rc.8",
|
|
85
85
|
"vue": "3.5.41"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@cloudflare/workers-types": "latest",
|
|
89
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
90
|
-
"@frockbot/plugin-tools": "0.3.
|
|
89
|
+
"@frockbot/plugin-prompt": "0.3.22",
|
|
90
|
+
"@frockbot/plugin-tools": "0.3.22",
|
|
91
91
|
"@types/bun": "1.3.6",
|
|
92
92
|
"@types/node": "26.2.0",
|
|
93
93
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ToolCall,
|
|
8
8
|
type ToolExecutionContext,
|
|
9
9
|
type TurnTypeV1,
|
|
10
|
+
TURN_DEADLINE_MS_V1,
|
|
10
11
|
} from "@frockbot/kernel-contracts";
|
|
11
12
|
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
12
13
|
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
PARENT_HANDOFF_CAPABILITY_V1,
|
|
20
21
|
SEND_MESSAGE_ALIAS_V1,
|
|
21
22
|
SEND_TO_USER_TOOL_V1,
|
|
23
|
+
TIME_BUDGET_WARNING_MS_V1,
|
|
22
24
|
USER_VOICE_CAPABILITY_V1,
|
|
23
25
|
WAKE_PARENT_TOOL_V1,
|
|
24
26
|
} from "./agent.ts";
|
|
@@ -575,6 +577,36 @@ describe("the acknowledgement reaches the user", () => {
|
|
|
575
577
|
}
|
|
576
578
|
});
|
|
577
579
|
|
|
580
|
+
test("the last two minutes of a Turn tell the model to send a status", async () => {
|
|
581
|
+
const mounted = await mount();
|
|
582
|
+
try {
|
|
583
|
+
const assemble = (remainingMs: number) =>
|
|
584
|
+
mounted.root.systemPrompt.assemble({
|
|
585
|
+
sessionId: "session-1",
|
|
586
|
+
provider: "provider-1",
|
|
587
|
+
model: "model-1",
|
|
588
|
+
turnType: "chat",
|
|
589
|
+
step: { current: 61, max: 64 },
|
|
590
|
+
deadline: {
|
|
591
|
+
at: TURN_DEADLINE_MS_V1,
|
|
592
|
+
now: TURN_DEADLINE_MS_V1 - remainingMs,
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
const boundary = await assemble(TIME_BUDGET_WARNING_MS_V1);
|
|
596
|
+
expect(boundary.text).not.toContain("<time_budget>");
|
|
597
|
+
const warning = await assemble(TIME_BUDGET_WARNING_MS_V1 - 1);
|
|
598
|
+
expect(warning.text).toContain("<time_budget>");
|
|
599
|
+
expect(warning.text).toContain("fewer than 2 minutes left");
|
|
600
|
+
expect(warning.text).toContain("Do not start new work");
|
|
601
|
+
expect(warning.text).toContain(SEND_TO_USER_TOOL_V1);
|
|
602
|
+
expect(warning.text.indexOf("<time_budget>")).toBeGreaterThan(
|
|
603
|
+
warning.text.indexOf("<step_budget>"),
|
|
604
|
+
);
|
|
605
|
+
} finally {
|
|
606
|
+
await mounted.dispose();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
578
610
|
test("a step that already spoke is left alone, and a replay adds nothing", async () => {
|
|
579
611
|
const mounted = await mount();
|
|
580
612
|
try {
|
package/src/agent.ts
CHANGED
|
@@ -234,6 +234,11 @@ export const STEP_BUDGET_PROMPT_SECTION_V1 = "step-budget";
|
|
|
234
234
|
export const STEP_BUDGET_PROMPT_ORDER_V1 = 90;
|
|
235
235
|
/** The section appears when this many steps or fewer remain after this one. */
|
|
236
236
|
export const STEP_BUDGET_WARNING_STEPS_V1 = 3;
|
|
237
|
+
export const TIME_BUDGET_PROMPT_SECTION_V1 = "time-budget";
|
|
238
|
+
/** Immediately after the step warning, so the time warning is read last. */
|
|
239
|
+
export const TIME_BUDGET_PROMPT_ORDER_V1 = 91;
|
|
240
|
+
/** The section appears only once strictly fewer than two minutes remain. */
|
|
241
|
+
export const TIME_BUDGET_WARNING_MS_V1 = 2 * 60_000;
|
|
237
242
|
|
|
238
243
|
/**
|
|
239
244
|
* What the model is told when its reply is about to be stopped.
|
|
@@ -265,6 +270,23 @@ export function stepBudgetPromptTextV1(context: {
|
|
|
265
270
|
].join("\n");
|
|
266
271
|
}
|
|
267
272
|
|
|
273
|
+
/** Warns by wall-clock budget even when the model has used few steps. */
|
|
274
|
+
export function timeBudgetPromptTextV1(context: {
|
|
275
|
+
deadline?: { at: number; now: number };
|
|
276
|
+
}): string {
|
|
277
|
+
const deadline = context.deadline;
|
|
278
|
+
if (!deadline) return "";
|
|
279
|
+
const remaining = deadline.at - deadline.now;
|
|
280
|
+
if (!Number.isFinite(remaining) || remaining >= TIME_BUDGET_WARNING_MS_V1) {
|
|
281
|
+
return "";
|
|
282
|
+
}
|
|
283
|
+
return [
|
|
284
|
+
"<time_budget>",
|
|
285
|
+
`This Turn has fewer than 2 minutes left before it is stopped automatically. Do not start new work. Call \`${SEND_TO_USER_TOOL_V1}\` now with a short status for the person: what is finished, what is not, and what they can do next.`,
|
|
286
|
+
"</time_budget>",
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
|
|
268
290
|
export const CONVERSATION_PROMPT_TEXT_V1 = [
|
|
269
291
|
"## Talking to the user",
|
|
270
292
|
"",
|
|
@@ -469,6 +491,11 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
469
491
|
order: STEP_BUDGET_PROMPT_ORDER_V1,
|
|
470
492
|
render: (context) => stepBudgetPromptTextV1(context),
|
|
471
493
|
}),
|
|
494
|
+
ctx.systemPrompt.register({
|
|
495
|
+
id: TIME_BUDGET_PROMPT_SECTION_V1,
|
|
496
|
+
order: TIME_BUDGET_PROMPT_ORDER_V1,
|
|
497
|
+
render: (context) => timeBudgetPromptTextV1(context),
|
|
498
|
+
}),
|
|
472
499
|
ctx.tools.register(
|
|
473
500
|
createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
|
|
474
501
|
userVoice ? { admissionCeiling: userVoice } : undefined,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveAppletCompositionV1,
|
|
9
9
|
APPLET_DIRECTORY_REVISION_SEEN_KEY,
|
|
10
10
|
type AppletInstanceBindingV1,
|
|
11
|
+
type AppletSourceSyncOutcomeV1,
|
|
11
12
|
type AppletUserDirectoryV1,
|
|
12
13
|
} from "./backend-applets.js";
|
|
13
14
|
import {
|
|
@@ -344,6 +345,7 @@ describe("ctx.applets", () => {
|
|
|
344
345
|
directory?: Partial<AppletUserDirectoryV1>;
|
|
345
346
|
instance?: Partial<AppletInstanceBindingV1>;
|
|
346
347
|
storage?: ReturnType<typeof memoryStorage>;
|
|
348
|
+
synced?: AppletSourceSyncOutcomeV1;
|
|
347
349
|
}) {
|
|
348
350
|
const storage = options.storage ?? memoryStorage();
|
|
349
351
|
const recorded: unknown[] = [];
|
|
@@ -455,6 +457,9 @@ describe("ctx.applets", () => {
|
|
|
455
457
|
stat: () =>
|
|
456
458
|
Promise.resolve({ status: "not-found", reason: "" }) as never,
|
|
457
459
|
},
|
|
460
|
+
...(options.synced
|
|
461
|
+
? { syncSourceRootNow: () => Promise.resolve(options.synced!) }
|
|
462
|
+
: {}),
|
|
458
463
|
composition: {
|
|
459
464
|
current: () => bootstrap(),
|
|
460
465
|
lastKnownGood: () => bootstrap(),
|
|
@@ -488,6 +493,39 @@ describe("ctx.applets", () => {
|
|
|
488
493
|
);
|
|
489
494
|
});
|
|
490
495
|
|
|
496
|
+
// Production, 2026-09-04: a 470 KB `dist/ui.html` the Bot had just built was
|
|
497
|
+
// reported as `not-found: run \`applet build\` … first`, and the Bot ran the
|
|
498
|
+
// build again, three times. The build was fine; the sync could not carry the
|
|
499
|
+
// file. The publish says which of the two it was now.
|
|
500
|
+
test("publish says a built file did not reach the Workspace rather than blaming the build", async () => {
|
|
501
|
+
const { host: capability } = host({
|
|
502
|
+
files: {},
|
|
503
|
+
synced: {
|
|
504
|
+
status: "degraded",
|
|
505
|
+
detail:
|
|
506
|
+
'1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
const outcome = await capability.publish({ appletId: APPLET }, scope);
|
|
510
|
+
expect(outcome.status).toBe("failed");
|
|
511
|
+
const reason = outcome.status === "failed" ? outcome.reason : "";
|
|
512
|
+
expect(reason).toBe(
|
|
513
|
+
'"dist/server.js" is on the Computer but did not reach the Workspace: 1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
|
|
514
|
+
);
|
|
515
|
+
expect(reason).not.toMatch(/applet build/);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("publish still blames the build when the pull of the source root was clean", async () => {
|
|
519
|
+
const { host: capability } = host({
|
|
520
|
+
files: {},
|
|
521
|
+
synced: { status: "ok", detail: "" },
|
|
522
|
+
});
|
|
523
|
+
const outcome = await capability.publish({ appletId: APPLET }, scope);
|
|
524
|
+
expect(outcome.status === "failed" && outcome.reason).toMatch(
|
|
525
|
+
/applet build/,
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
491
529
|
test("publish refuses a manifest whose hashes do not match the bytes", async () => {
|
|
492
530
|
const { host: capability } = host({
|
|
493
531
|
files: {
|
package/src/backend-applets.ts
CHANGED
|
@@ -58,6 +58,20 @@ export const APPLET_PUBLISH_EFFECT_PREFIX = "applets:publish-effect:";
|
|
|
58
58
|
*/
|
|
59
59
|
export const APPLET_DIRECTORY_REVISION_SEEN_KEY = "applets:directory-revision";
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* How the pre-publish pull of the Applet's source root went.
|
|
63
|
+
*
|
|
64
|
+
* `ok` and `skipped` mean the store now holds what the Computer holds. Anything
|
|
65
|
+
* else means the two may differ, and a `dist/` file the publish cannot find is
|
|
66
|
+
* then far more likely to be one the sync could not carry than one `applet
|
|
67
|
+
* build` never wrote.
|
|
68
|
+
*/
|
|
69
|
+
export interface AppletSourceSyncOutcomeV1 {
|
|
70
|
+
status: "ok" | "degraded" | "unavailable" | "refused" | "skipped";
|
|
71
|
+
/** What the sync said went wrong, empty when it had nothing to say. */
|
|
72
|
+
detail: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
61
75
|
/** The three files `applet build` writes, read from the durable root. */
|
|
62
76
|
export const APPLET_DIST_FILES_V1 = [
|
|
63
77
|
"dist/server.js",
|
|
@@ -293,8 +307,12 @@ export interface AppletCapabilityHostOptionsV1 {
|
|
|
293
307
|
* store is read as it stands — correct but possibly stale — and the Bot is
|
|
294
308
|
* told so in the failure when the files are missing, rather than the publish
|
|
295
309
|
* silently using old bytes.
|
|
310
|
+
*
|
|
311
|
+
* What it answers is how that pull went. A build that is on the Computer but
|
|
312
|
+
* did not reach the store is a different failure from a build that was never
|
|
313
|
+
* run, and the publish can only say which when it is told.
|
|
296
314
|
*/
|
|
297
|
-
syncSourceRootNow?(appletId: string): Promise<
|
|
315
|
+
syncSourceRootNow?(appletId: string): Promise<AppletSourceSyncOutcomeV1>;
|
|
298
316
|
composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
|
|
299
317
|
now?(): Date;
|
|
300
318
|
}
|
|
@@ -594,11 +612,20 @@ export function createAppletCapabilityHostV1(
|
|
|
594
612
|
appletId: string,
|
|
595
613
|
file: string,
|
|
596
614
|
maximum: number,
|
|
615
|
+
synced?: AppletSourceSyncOutcomeV1,
|
|
597
616
|
): Promise<{ text: string } | { failure: string }> {
|
|
598
617
|
const outcome = await options.workspace.read(
|
|
599
618
|
appletDistPathV1(options.userId, appletId, file),
|
|
600
619
|
);
|
|
601
620
|
if (outcome.status !== "ok") {
|
|
621
|
+
// A pull that did not finish is the likelier explanation than an
|
|
622
|
+
// unbuilt Applet, and telling the Bot to build again would send it
|
|
623
|
+
// round a loop that cannot end.
|
|
624
|
+
if (synced && synced.status !== "ok" && synced.status !== "skipped") {
|
|
625
|
+
return {
|
|
626
|
+
failure: `"${file}" is on the Computer but did not reach the Workspace: ${synced.detail || "the sync could not carry it"}`,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
602
629
|
return {
|
|
603
630
|
failure: `"${file}" is ${outcome.status}: run \`applet build\` in applets/${appletId} on the Computer first`,
|
|
604
631
|
};
|
|
@@ -731,12 +758,13 @@ export function createAppletCapabilityHostV1(
|
|
|
731
758
|
|
|
732
759
|
// Force a pull of the source root so the publish sees what the Bot just
|
|
733
760
|
// built, not the last synced copy. See the seam note on the option.
|
|
734
|
-
await options.syncSourceRootNow?.(input.appletId);
|
|
761
|
+
const synced = await options.syncSourceRootNow?.(input.appletId);
|
|
735
762
|
|
|
736
763
|
const server = await readFile(
|
|
737
764
|
input.appletId,
|
|
738
765
|
"dist/server.js",
|
|
739
766
|
APPLET_MAX_SERVER_BYTES_V1,
|
|
767
|
+
synced,
|
|
740
768
|
);
|
|
741
769
|
if ("failure" in server) {
|
|
742
770
|
return settle(failed(input.appletId, "unbuilt", server.failure));
|
|
@@ -745,6 +773,7 @@ export function createAppletCapabilityHostV1(
|
|
|
745
773
|
input.appletId,
|
|
746
774
|
"dist/ui.html",
|
|
747
775
|
APPLET_MAX_UI_BYTES_V1,
|
|
776
|
+
synced,
|
|
748
777
|
);
|
|
749
778
|
if ("failure" in ui) {
|
|
750
779
|
return settle(failed(input.appletId, "unbuilt", ui.failure));
|
|
@@ -753,6 +782,7 @@ export function createAppletCapabilityHostV1(
|
|
|
753
782
|
input.appletId,
|
|
754
783
|
"dist/manifest.json",
|
|
755
784
|
APPLET_MAX_MANIFEST_BYTES_V1,
|
|
785
|
+
synced,
|
|
756
786
|
);
|
|
757
787
|
if ("failure" in manifestFile) {
|
|
758
788
|
return settle(failed(input.appletId, "unbuilt", manifestFile.failure));
|
package/src/backend.ts
CHANGED
|
@@ -2492,7 +2492,9 @@ export class ShellBotBackendContribution {
|
|
|
2492
2492
|
sessions: typeof active.mounted.runtime.root.sessions;
|
|
2493
2493
|
};
|
|
2494
2494
|
const computerIdentity = { userId: identity.userId };
|
|
2495
|
-
if (!root.computers?.assignment(computerIdentity))
|
|
2495
|
+
if (!root.computers?.assignment(computerIdentity)) {
|
|
2496
|
+
return { status: "skipped", detail: "" } as const;
|
|
2497
|
+
}
|
|
2496
2498
|
const session = root.sessions.get(active.sessionId);
|
|
2497
2499
|
const started = session?.events.findLast(
|
|
2498
2500
|
(event) => event.type === "step/start",
|
|
@@ -2503,7 +2505,7 @@ export class ShellBotBackendContribution {
|
|
|
2503
2505
|
{ botId: identity.botId },
|
|
2504
2506
|
{ signal: active.signal },
|
|
2505
2507
|
);
|
|
2506
|
-
await syncWorkspaceRootNowV1({
|
|
2508
|
+
const summary = await syncWorkspaceRootNowV1({
|
|
2507
2509
|
computer,
|
|
2508
2510
|
sessions: root.sessions,
|
|
2509
2511
|
sessionId: active.sessionId,
|
|
@@ -2514,6 +2516,7 @@ export class ShellBotBackendContribution {
|
|
|
2514
2516
|
),
|
|
2515
2517
|
signal: active.signal,
|
|
2516
2518
|
});
|
|
2519
|
+
return { status: summary.status, detail: summary.detail };
|
|
2517
2520
|
}
|
|
2518
2521
|
: undefined,
|
|
2519
2522
|
composition: {
|