@frockbot/plugin-shell 0.3.16 → 0.3.18
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 +1 -1
- package/package.json +33 -32
- package/src/agent.test.ts +85 -2
- package/src/agent.ts +87 -20
- package/src/backend-applets.ts +2 -2
- package/src/backend-composition.ts +20 -0
- package/src/backend-flock.ts +119 -10
- package/src/backend-recovery-integration.test.ts +1 -1
- package/src/backend-runner.ts +1 -0
- package/src/backend.ts +100 -1
- package/src/client/FrockBotApp.vue +6 -1
- package/src/client/index.test.ts +61 -0
- package/src/client/index.ts +43 -10
- package/src/client/mobile-safe-area.test.ts +25 -0
- package/src/client/styles.css +28 -0
- package/src/client/voice-microphone.ts +13 -3
- package/src/compaction.test.ts +26 -0
- package/src/compaction.ts +46 -14
- package/src/history.ts +8 -2
- package/src/run-failure-copy.test.ts +11 -0
- package/src/run-failure-copy.ts +5 -1
- package/src/run-protocol.test.ts +73 -4
- package/src/run-protocol.ts +118 -12
- package/src/shared.ts +3 -0
package/frockbot.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./debug-protocol": "./src/debug-protocol.ts",
|
|
27
27
|
"./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
|
|
28
28
|
"./client/notify": "./src/client/notify.ts",
|
|
29
|
+
"./client/voice-microphone": "./src/client/voice-microphone.ts",
|
|
29
30
|
"./client/styles.css": "./src/client/styles.css",
|
|
30
31
|
"./client/voice-worklet": "./src/client/voice-worklet.ts",
|
|
31
32
|
"./focus": "./src/focus.ts",
|
|
@@ -51,42 +52,42 @@
|
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
54
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
54
|
-
"@frockbot/agent-runtime": "0.3.
|
|
55
|
-
"@frockbot/application-foundation": "0.3.
|
|
56
|
-
"@frockbot/catalog-core": "0.3.
|
|
57
|
-
"@frockbot/client-core": "0.3.
|
|
58
|
-
"@frockbot/client-ui": "0.3.
|
|
59
|
-
"@frockbot/computer-core": "0.3.
|
|
60
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
61
|
-
"@frockbot/configuration-core": "0.3.
|
|
62
|
-
"@frockbot/connection-core": "0.3.
|
|
63
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
64
|
-
"@frockbot/kernel-composition": "0.3.
|
|
65
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
66
|
-
"@frockbot/kernel-do": "0.3.
|
|
67
|
-
"@frockbot/machine-protocol": "0.3.
|
|
68
|
-
"@frockbot/plugin-applets": "0.3.
|
|
69
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
70
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
71
|
-
"@frockbot/plugin-computer": "0.3.
|
|
72
|
-
"@frockbot/plugin-flock": "0.3.
|
|
73
|
-
"@frockbot/plugin-image": "0.3.
|
|
74
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
75
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
76
|
-
"@frockbot/plugin-memory": "0.3.
|
|
77
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
78
|
-
"@frockbot/plugin-routines": "0.3.
|
|
79
|
-
"@frockbot/plugin-skills": "0.3.
|
|
80
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
81
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
82
|
-
"@frockbot/protocol": "0.3.
|
|
55
|
+
"@frockbot/agent-runtime": "0.3.18",
|
|
56
|
+
"@frockbot/application-foundation": "0.3.18",
|
|
57
|
+
"@frockbot/catalog-core": "0.3.18",
|
|
58
|
+
"@frockbot/client-core": "0.3.18",
|
|
59
|
+
"@frockbot/client-ui": "0.3.18",
|
|
60
|
+
"@frockbot/computer-core": "0.3.18",
|
|
61
|
+
"@frockbot/computer-host-protocol": "0.3.18",
|
|
62
|
+
"@frockbot/configuration-core": "0.3.18",
|
|
63
|
+
"@frockbot/connection-core": "0.3.18",
|
|
64
|
+
"@frockbot/kernel-agent-loop": "0.3.18",
|
|
65
|
+
"@frockbot/kernel-composition": "0.3.18",
|
|
66
|
+
"@frockbot/kernel-contracts": "0.3.18",
|
|
67
|
+
"@frockbot/kernel-do": "0.3.18",
|
|
68
|
+
"@frockbot/machine-protocol": "0.3.18",
|
|
69
|
+
"@frockbot/plugin-applets": "0.3.18",
|
|
70
|
+
"@frockbot/plugin-authoring": "0.3.18",
|
|
71
|
+
"@frockbot/plugin-bot-template": "0.3.18",
|
|
72
|
+
"@frockbot/plugin-computer": "0.3.18",
|
|
73
|
+
"@frockbot/plugin-flock": "0.3.18",
|
|
74
|
+
"@frockbot/plugin-image": "0.3.18",
|
|
75
|
+
"@frockbot/plugin-machine-messages": "0.3.18",
|
|
76
|
+
"@frockbot/plugin-mcp": "0.3.18",
|
|
77
|
+
"@frockbot/plugin-memory": "0.3.18",
|
|
78
|
+
"@frockbot/plugin-package-catalog": "0.3.18",
|
|
79
|
+
"@frockbot/plugin-routines": "0.3.18",
|
|
80
|
+
"@frockbot/plugin-skills": "0.3.18",
|
|
81
|
+
"@frockbot/plugin-subagents": "0.3.18",
|
|
82
|
+
"@frockbot/plugin-user-machine": "0.3.18",
|
|
83
|
+
"@frockbot/protocol": "0.3.18",
|
|
83
84
|
"cordis": "4.0.0-rc.8",
|
|
84
85
|
"vue": "3.5.41"
|
|
85
86
|
},
|
|
86
87
|
"devDependencies": {
|
|
87
88
|
"@cloudflare/workers-types": "latest",
|
|
88
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
89
|
-
"@frockbot/plugin-tools": "0.3.
|
|
89
|
+
"@frockbot/plugin-prompt": "0.3.18",
|
|
90
|
+
"@frockbot/plugin-tools": "0.3.18",
|
|
90
91
|
"@types/bun": "1.3.6",
|
|
91
92
|
"@types/node": "26.2.0",
|
|
92
93
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.test.ts
CHANGED
|
@@ -74,7 +74,7 @@ async function invoke(
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
describe("the Shell's tool admission", () => {
|
|
77
|
-
test("offers the send tool
|
|
77
|
+
test("offers the send tool on chat and agent Turns", async () => {
|
|
78
78
|
const mounted = await mount();
|
|
79
79
|
try {
|
|
80
80
|
const chat = mounted.root.tools
|
|
@@ -86,10 +86,16 @@ describe("the Shell's tool admission", () => {
|
|
|
86
86
|
const subagent = mounted.root.tools
|
|
87
87
|
.schemas({ turnType: "subagent" })
|
|
88
88
|
.map((tool) => tool.name);
|
|
89
|
+
const agent = mounted.root.tools
|
|
90
|
+
.schemas({ turnType: "agent" })
|
|
91
|
+
.map((tool) => tool.name);
|
|
89
92
|
|
|
90
93
|
expect(chat).toContain(SEND_TO_USER_TOOL_V1);
|
|
91
94
|
expect(chat).toContain(SEND_MESSAGE_ALIAS_V1);
|
|
92
95
|
expect(chat).not.toContain(WAKE_PARENT_TOOL_V1);
|
|
96
|
+
expect(agent).toContain(SEND_TO_USER_TOOL_V1);
|
|
97
|
+
expect(agent).toContain(SEND_MESSAGE_ALIAS_V1);
|
|
98
|
+
expect(agent).not.toContain(WAKE_PARENT_TOOL_V1);
|
|
93
99
|
expect(automation).toEqual([
|
|
94
100
|
WAKE_PARENT_TOOL_V1,
|
|
95
101
|
"get_dynamic_tools",
|
|
@@ -179,7 +185,10 @@ describe("the Shell's tool admission", () => {
|
|
|
179
185
|
});
|
|
180
186
|
|
|
181
187
|
test("bounds each tool by the turn types its manifest Capability declares", () => {
|
|
182
|
-
expect(shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1)).toEqual([
|
|
188
|
+
expect(shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1)).toEqual([
|
|
189
|
+
"chat",
|
|
190
|
+
"agent",
|
|
191
|
+
]);
|
|
183
192
|
expect(shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1)).toEqual([
|
|
184
193
|
"automation",
|
|
185
194
|
"subagent",
|
|
@@ -492,6 +501,80 @@ describe("the acknowledgement reaches the user", () => {
|
|
|
492
501
|
}
|
|
493
502
|
});
|
|
494
503
|
|
|
504
|
+
test("a step that is about to call send_to_user is not promoted", async () => {
|
|
505
|
+
const mounted = await mount();
|
|
506
|
+
try {
|
|
507
|
+
// Bob on production, 2026-09-04: the model wrote the acknowledgement as
|
|
508
|
+
// plain text and passed the same line to `send_to_user` in one step, and
|
|
509
|
+
// the person saw two identical bubbles.
|
|
510
|
+
await mounted.root.serial(
|
|
511
|
+
"agent/assistant-text",
|
|
512
|
+
{ session: mounted.session } as never,
|
|
513
|
+
"On it — building your to-do applet now.",
|
|
514
|
+
{
|
|
515
|
+
turn: 3,
|
|
516
|
+
step: 1,
|
|
517
|
+
requestId: "request-1",
|
|
518
|
+
toolNames: [SEND_TO_USER_TOOL_V1, "applet_create"],
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
await mounted.root.serial(
|
|
522
|
+
"agent/assistant-text",
|
|
523
|
+
{ session: mounted.session } as never,
|
|
524
|
+
"Sending it another way.",
|
|
525
|
+
{
|
|
526
|
+
turn: 3,
|
|
527
|
+
step: 2,
|
|
528
|
+
requestId: "request-2",
|
|
529
|
+
toolNames: [SEND_MESSAGE_ALIAS_V1],
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const sends = mounted.session.events.filter(
|
|
534
|
+
(event) => event.type === "send/to-user",
|
|
535
|
+
);
|
|
536
|
+
expect(sends).toHaveLength(0);
|
|
537
|
+
} finally {
|
|
538
|
+
await mounted.dispose();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("the last steps of a reply tell the model to send a status", async () => {
|
|
543
|
+
const mounted = await mount();
|
|
544
|
+
try {
|
|
545
|
+
const assemble = (current: number) =>
|
|
546
|
+
mounted.root.systemPrompt.assemble({
|
|
547
|
+
sessionId: "session-1",
|
|
548
|
+
provider: "provider-1",
|
|
549
|
+
model: "model-1",
|
|
550
|
+
turnType: "chat",
|
|
551
|
+
step: { current, max: 64 },
|
|
552
|
+
});
|
|
553
|
+
const early = await assemble(10);
|
|
554
|
+
expect(early.text).not.toContain("<step_budget>");
|
|
555
|
+
const warning = await assemble(61);
|
|
556
|
+
expect(warning.text).toContain("<step_budget>");
|
|
557
|
+
expect(warning.text).toContain("3 steps left after this one");
|
|
558
|
+
expect(warning.text).toContain(SEND_TO_USER_TOOL_V1);
|
|
559
|
+
const last = await assemble(64);
|
|
560
|
+
expect(last.text).toContain("This is the last step of this reply");
|
|
561
|
+
// The budget section sits after the conversation contract, where the
|
|
562
|
+
// model reads it last.
|
|
563
|
+
expect(last.text.indexOf("<step_budget>")).toBeGreaterThan(
|
|
564
|
+
last.text.indexOf("send_to_user"),
|
|
565
|
+
);
|
|
566
|
+
const outside = await mounted.root.systemPrompt.assemble({
|
|
567
|
+
sessionId: "session-1",
|
|
568
|
+
provider: "provider-1",
|
|
569
|
+
model: "model-1",
|
|
570
|
+
turnType: "chat",
|
|
571
|
+
});
|
|
572
|
+
expect(outside.text).not.toContain("<step_budget>");
|
|
573
|
+
} finally {
|
|
574
|
+
await mounted.dispose();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
495
578
|
test("a step that already spoke is left alone, and a replay adds nothing", async () => {
|
|
496
579
|
const mounted = await mount();
|
|
497
580
|
try {
|
package/src/agent.ts
CHANGED
|
@@ -46,7 +46,12 @@ import {
|
|
|
46
46
|
turnScopedMessagesV1,
|
|
47
47
|
turnTypesByTurnV1,
|
|
48
48
|
} from "./history.js";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
COMPACTION_RESPONSE_SCHEMA_V1,
|
|
51
|
+
type CompactionSummaryPayloadV1,
|
|
52
|
+
renderCompactionSummaryV1,
|
|
53
|
+
runCompactionV1,
|
|
54
|
+
} from "./compaction.js";
|
|
50
55
|
import { compactionWorkV1 } from "./compaction-scheduler.js";
|
|
51
56
|
import type { Plugin } from "cordis";
|
|
52
57
|
import manifest from "../frockbot.json" with { type: "json" };
|
|
@@ -155,11 +160,25 @@ export function promotedSendOccurrenceIdV1(requestId: string): string {
|
|
|
155
160
|
export async function promoteAssistantTextToSendV1(
|
|
156
161
|
session: Session,
|
|
157
162
|
text: string,
|
|
158
|
-
position: {
|
|
163
|
+
position: {
|
|
164
|
+
turn: number;
|
|
165
|
+
step: number;
|
|
166
|
+
requestId: string;
|
|
167
|
+
toolNames?: readonly string[];
|
|
168
|
+
},
|
|
159
169
|
): Promise<void> {
|
|
160
170
|
const trimmed = text.trim();
|
|
161
171
|
if (trimmed.length === 0 || trimmed.length > PROMOTED_ASSISTANT_TEXT_LIMIT_V1)
|
|
162
172
|
return;
|
|
173
|
+
// A step that narrates "On it — building it now." AND calls `send_to_user`
|
|
174
|
+
// with the same line is about to speak for itself; promoting the narration
|
|
175
|
+
// too is how the person saw the bubble twice. The send has not run yet when
|
|
176
|
+
// this hook fires (it runs before the tools, on purpose), so the pending
|
|
177
|
+
// call names are the only evidence.
|
|
178
|
+
const aboutToSend = (position.toolNames ?? []).some(
|
|
179
|
+
(name) => name === SEND_TO_USER_TOOL_V1 || name === SEND_MESSAGE_ALIAS_V1,
|
|
180
|
+
);
|
|
181
|
+
if (aboutToSend) return;
|
|
163
182
|
const occurrenceId = promotedSendOccurrenceIdV1(position.requestId);
|
|
164
183
|
const spokeThisStep = session.events.some(
|
|
165
184
|
(event) =>
|
|
@@ -210,6 +229,42 @@ export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
|
|
|
210
229
|
/** Ordered after identity (0), before anything a Package contributes. */
|
|
211
230
|
export const CONVERSATION_PROMPT_ORDER_V1 = 1;
|
|
212
231
|
|
|
232
|
+
export const STEP_BUDGET_PROMPT_SECTION_V1 = "step-budget";
|
|
233
|
+
/** Late in the prompt, where the model reads it last and heeds it most. */
|
|
234
|
+
export const STEP_BUDGET_PROMPT_ORDER_V1 = 90;
|
|
235
|
+
/** The section appears when this many steps or fewer remain after this one. */
|
|
236
|
+
export const STEP_BUDGET_WARNING_STEPS_V1 = 3;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* What the model is told when its reply is about to be stopped.
|
|
240
|
+
*
|
|
241
|
+
* A Turn that reaches the loop's step ceiling ends `interrupted`, and a model
|
|
242
|
+
* that was mid-work says nothing: the person sees a notice and no status. Bob
|
|
243
|
+
* (2026-09-04) spent twenty steps retrying a failing publish and the thread
|
|
244
|
+
* went quiet. So the last few steps carry a countdown and one instruction —
|
|
245
|
+
* stop starting work, send a status — and the final step allows nothing else.
|
|
246
|
+
*/
|
|
247
|
+
export function stepBudgetPromptTextV1(context: {
|
|
248
|
+
step?: { current: number; max: number };
|
|
249
|
+
}): string {
|
|
250
|
+
const step = context.step;
|
|
251
|
+
if (!step) return "";
|
|
252
|
+
const remaining = step.max - step.current;
|
|
253
|
+
if (remaining > STEP_BUDGET_WARNING_STEPS_V1 || remaining < 0) return "";
|
|
254
|
+
if (remaining === 0) {
|
|
255
|
+
return [
|
|
256
|
+
"<step_budget>",
|
|
257
|
+
`This is the last step of this reply; after it the reply is stopped automatically. Do nothing except call \`${SEND_TO_USER_TOOL_V1}\` once with a short status for the person: what is finished, what is not, and what they can do next.`,
|
|
258
|
+
"</step_budget>",
|
|
259
|
+
].join("\n");
|
|
260
|
+
}
|
|
261
|
+
return [
|
|
262
|
+
"<step_budget>",
|
|
263
|
+
`This reply has ${remaining} ${remaining === 1 ? "step" : "steps"} left after this one 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.`,
|
|
264
|
+
"</step_budget>",
|
|
265
|
+
].join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
213
268
|
export const CONVERSATION_PROMPT_TEXT_V1 = [
|
|
214
269
|
"## Talking to the user",
|
|
215
270
|
"",
|
|
@@ -271,7 +326,7 @@ function createSendToUserTool(
|
|
|
271
326
|
string,
|
|
272
327
|
unknown
|
|
273
328
|
>,
|
|
274
|
-
admission: { turnTypes: ["chat"] },
|
|
329
|
+
admission: { turnTypes: ["chat", "agent"] },
|
|
275
330
|
validate: (input: unknown) =>
|
|
276
331
|
typeof input === "object" && input !== null && !Array.isArray(input),
|
|
277
332
|
execute: async (
|
|
@@ -407,6 +462,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
407
462
|
order: CONVERSATION_PROMPT_ORDER_V1,
|
|
408
463
|
render: () => CONVERSATION_PROMPT_TEXT_V1,
|
|
409
464
|
}),
|
|
465
|
+
// Empty for most of a Turn; a countdown and one instruction at the end of
|
|
466
|
+
// its step budget. See `stepBudgetPromptTextV1`.
|
|
467
|
+
ctx.systemPrompt.register({
|
|
468
|
+
id: STEP_BUDGET_PROMPT_SECTION_V1,
|
|
469
|
+
order: STEP_BUDGET_PROMPT_ORDER_V1,
|
|
470
|
+
render: (context) => stepBudgetPromptTextV1(context),
|
|
471
|
+
}),
|
|
410
472
|
ctx.tools.register(
|
|
411
473
|
createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
|
|
412
474
|
userVoice ? { admissionCeiling: userVoice } : undefined,
|
|
@@ -449,27 +511,32 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
449
511
|
currentTurn: turn,
|
|
450
512
|
newEffectId: () => `compaction-${crypto.randomUUID()}`,
|
|
451
513
|
summarise: async (request) => {
|
|
452
|
-
let text = "";
|
|
453
514
|
// Two deadlines, one call: the compaction's own, and the abort
|
|
454
515
|
// a newly admitted Turn raises when it takes the log back.
|
|
455
516
|
const cancelled = AbortSignal.any([request.signal, signal]);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
517
|
+
const result =
|
|
518
|
+
await scoped.llm.structured<CompactionSummaryPayloadV1>(
|
|
519
|
+
{
|
|
520
|
+
requestId: `compaction-${crypto.randomUUID()}`,
|
|
521
|
+
provider: request.provider,
|
|
522
|
+
model: request.model,
|
|
523
|
+
system: request.system,
|
|
524
|
+
messages: request.messages,
|
|
525
|
+
tools: [],
|
|
526
|
+
...(request.modelBinding
|
|
527
|
+
? { modelBinding: request.modelBinding }
|
|
528
|
+
: {}),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "conversation_compaction",
|
|
532
|
+
schema: COMPACTION_RESPONSE_SCHEMA_V1,
|
|
533
|
+
},
|
|
534
|
+
cancelled,
|
|
535
|
+
);
|
|
536
|
+
if (result.status === "failed") {
|
|
537
|
+
throw new Error(result.failure.message);
|
|
471
538
|
}
|
|
472
|
-
return
|
|
539
|
+
return renderCompactionSummaryV1(result.value);
|
|
473
540
|
},
|
|
474
541
|
});
|
|
475
542
|
});
|
package/src/backend-applets.ts
CHANGED
|
@@ -294,7 +294,7 @@ export interface AppletCapabilityHostOptionsV1 {
|
|
|
294
294
|
* told so in the failure when the files are missing, rather than the publish
|
|
295
295
|
* silently using old bytes.
|
|
296
296
|
*/
|
|
297
|
-
syncSourceRootNow?(): Promise<void>;
|
|
297
|
+
syncSourceRootNow?(appletId: string): Promise<void>;
|
|
298
298
|
composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
|
|
299
299
|
now?(): Date;
|
|
300
300
|
}
|
|
@@ -731,7 +731,7 @@ export function createAppletCapabilityHostV1(
|
|
|
731
731
|
|
|
732
732
|
// Force a pull of the source root so the publish sees what the Bot just
|
|
733
733
|
// built, not the last synced copy. See the seam note on the option.
|
|
734
|
-
await options.syncSourceRootNow?.();
|
|
734
|
+
await options.syncSourceRootNow?.(input.appletId);
|
|
735
735
|
|
|
736
736
|
const server = await readFile(
|
|
737
737
|
input.appletId,
|
|
@@ -112,6 +112,11 @@ export interface ShellCompositionMountOptions {
|
|
|
112
112
|
agentPackages?: readonly FoundationAgentPackage[];
|
|
113
113
|
modelSelection?: RuntimeModelSelection;
|
|
114
114
|
systemPromptSection?: string;
|
|
115
|
+
/** Called after the loop has flushed `turn/end`; errors are non-fatal. */
|
|
116
|
+
onTurnStopping?(input: {
|
|
117
|
+
turn: number;
|
|
118
|
+
events: readonly SessionEvent[];
|
|
119
|
+
}): Promise<void>;
|
|
115
120
|
/**
|
|
116
121
|
* Durably linearizes each provider or tool effect against Stop immediately
|
|
117
122
|
* before it is used. The Bot Durable Object owns the transaction; the mounted
|
|
@@ -180,6 +185,20 @@ export function createShellCompositionHost(
|
|
|
180
185
|
...(options.turnType ? { turnType: options.turnType } : {}),
|
|
181
186
|
...(options.subagentRole ? { subagentRole: options.subagentRole } : {}),
|
|
182
187
|
});
|
|
188
|
+
const disposeTurnStopping = options.onTurnStopping
|
|
189
|
+
? runtime.root.on("agent/turn-stopping", async (agent, turn) => {
|
|
190
|
+
try {
|
|
191
|
+
await options.onTurnStopping!({
|
|
192
|
+
turn,
|
|
193
|
+
events: structuredClone(agent.session.events),
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
// Accounting is a durable projection. Its own outbox exposes a
|
|
197
|
+
// gap; it can never turn a completed model response into a
|
|
198
|
+
// failed conversation Turn.
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
: undefined;
|
|
183
202
|
|
|
184
203
|
const isolateMembers = generation.members.filter(
|
|
185
204
|
(member) => member.artifact !== undefined,
|
|
@@ -290,6 +309,7 @@ export function createShellCompositionHost(
|
|
|
290
309
|
}
|
|
291
310
|
|
|
292
311
|
const dispose = async () => {
|
|
312
|
+
disposeTurnStopping?.();
|
|
293
313
|
for (const unregister of unregisterApplets.toReversed()) unregister();
|
|
294
314
|
for (const contribution of active.toReversed()) {
|
|
295
315
|
await contribution.dispose();
|
package/src/backend-flock.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// The Bot Durable Object's half of the Bot self-management seam.
|
|
2
2
|
//
|
|
3
|
-
// The Flock Package offers a Bot
|
|
4
|
-
// User's flock. This module decides, for one admitted
|
|
5
|
-
// those
|
|
6
|
-
//
|
|
7
|
-
// command,
|
|
8
|
-
//
|
|
3
|
+
// The Flock Package offers a Bot self-management and direct messaging over its
|
|
4
|
+
// own identity and its User's flock. This module decides, for one admitted
|
|
5
|
+
// Turn, what provenance those effects record and which authorities they may
|
|
6
|
+
// reach. The profile write is the Bot Durable Object's own configuration
|
|
7
|
+
// command, create is the User Durable Object's `bot/create`, and messaging
|
|
8
|
+
// admits an agent Turn through the target Bot's Durable Object.
|
|
9
9
|
//
|
|
10
10
|
// AUTHORITY. "Self-modification never widens authority." The host handed to
|
|
11
|
-
// the Package exposes
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// the Package exposes only narrow authority calls. The `botId` on every
|
|
12
|
+
// mutation is fixed here rather than taken from the model's arguments. A Bot
|
|
13
|
+
// cannot address another Bot's settings through this seam; a message target is
|
|
14
|
+
// instead resolved from the same User's Flock directory before admission.
|
|
15
15
|
//
|
|
16
16
|
// HIBERNATION. Nothing here reaches the Computer registry, a Computer
|
|
17
17
|
// provider, or a Sprite: identity is Durable Object state, so self-management
|
|
@@ -26,7 +26,9 @@ import type {
|
|
|
26
26
|
CreateBotCommandV1,
|
|
27
27
|
FlockReceiptV1,
|
|
28
28
|
FlockSelfRuntimeHostV1,
|
|
29
|
+
BotMessageOutcomeV1,
|
|
29
30
|
} from "@frockbot/plugin-flock/agent";
|
|
31
|
+
import type { AgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
|
|
30
32
|
|
|
31
33
|
/** The Bot and User whose identity a Turn may change. */
|
|
32
34
|
export interface BotSelfManagementIdentity {
|
|
@@ -39,6 +41,9 @@ export interface BotSelfManagementTurn {
|
|
|
39
41
|
runId: string;
|
|
40
42
|
turnId: string;
|
|
41
43
|
sessionId: string;
|
|
44
|
+
/** This Turn's pinned profile name, stable across effect recovery. */
|
|
45
|
+
fromBotName: string;
|
|
46
|
+
inboundAgent?: FlockSelfRuntimeHostV1["inboundAgent"];
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/**
|
|
@@ -57,6 +62,51 @@ export interface BotSelfManagementAuthorities {
|
|
|
57
62
|
userId: string,
|
|
58
63
|
command: CreateBotCommandV1,
|
|
59
64
|
): Promise<FlockReceiptV1>;
|
|
65
|
+
reserveAgentTurn(request: {
|
|
66
|
+
schemaVersion: 1;
|
|
67
|
+
userId: string;
|
|
68
|
+
requesterId: string;
|
|
69
|
+
runId: string;
|
|
70
|
+
reservedAt: string;
|
|
71
|
+
}): Promise<AgentTurnSlotReceiptV1>;
|
|
72
|
+
releaseAgentTurn(request: {
|
|
73
|
+
schemaVersion: 1;
|
|
74
|
+
userId: string;
|
|
75
|
+
requesterId: string;
|
|
76
|
+
runId: string;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
runAgent(request: {
|
|
79
|
+
schemaVersion: 1;
|
|
80
|
+
userId: string;
|
|
81
|
+
botId: string;
|
|
82
|
+
command: {
|
|
83
|
+
runId: string;
|
|
84
|
+
sessionId: string;
|
|
85
|
+
acceptedAt: string;
|
|
86
|
+
text: string;
|
|
87
|
+
source: {
|
|
88
|
+
kind: "bot";
|
|
89
|
+
fromBotId: string;
|
|
90
|
+
fromBotName: string;
|
|
91
|
+
messageId: string;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}): Promise<{ text: string }>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function agentRunIdV1(
|
|
98
|
+
identity: BotSelfManagementIdentity,
|
|
99
|
+
targetBotId: string,
|
|
100
|
+
effectId: string,
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
const bytes = new TextEncoder().encode(
|
|
103
|
+
`${identity.userId}\u0000${identity.botId}\u0000${targetBotId}\u0000${effectId}`,
|
|
104
|
+
);
|
|
105
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
106
|
+
const hex = [...new Uint8Array(digest)]
|
|
107
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
108
|
+
.join("");
|
|
109
|
+
return `agent-${hex.slice(0, 32)}`;
|
|
60
110
|
}
|
|
61
111
|
|
|
62
112
|
/**
|
|
@@ -92,5 +142,64 @@ export function createBotSelfManagementHost(
|
|
|
92
142
|
},
|
|
93
143
|
listBots: () => authorities.listBots(identity.userId),
|
|
94
144
|
createBot: (command) => authorities.createBot(identity.userId, command),
|
|
145
|
+
...(turn.inboundAgent ? { inboundAgent: turn.inboundAgent } : {}),
|
|
146
|
+
messageBot: async (request): Promise<BotMessageOutcomeV1> => {
|
|
147
|
+
if (request.targetBotId === identity.botId) {
|
|
148
|
+
throw new Error("a Bot cannot message itself");
|
|
149
|
+
}
|
|
150
|
+
const directory = await authorities.listBots(identity.userId);
|
|
151
|
+
const target = directory.bots.find(
|
|
152
|
+
(bot) => bot.botId === request.targetBotId,
|
|
153
|
+
);
|
|
154
|
+
if (!target)
|
|
155
|
+
throw new Error("the target Bot is not in this User's flock");
|
|
156
|
+
const runId = await agentRunIdV1(
|
|
157
|
+
identity,
|
|
158
|
+
request.targetBotId,
|
|
159
|
+
request.effectId,
|
|
160
|
+
);
|
|
161
|
+
const reservation = await authorities.reserveAgentTurn({
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
userId: identity.userId,
|
|
164
|
+
requesterId: identity.botId,
|
|
165
|
+
runId,
|
|
166
|
+
reservedAt: new Date().toISOString(),
|
|
167
|
+
});
|
|
168
|
+
if (reservation.status === "refused") {
|
|
169
|
+
throw new Error(reservation.reason);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const turnResult = await authorities.runAgent({
|
|
173
|
+
schemaVersion: 1,
|
|
174
|
+
userId: identity.userId,
|
|
175
|
+
botId: request.targetBotId,
|
|
176
|
+
command: {
|
|
177
|
+
runId,
|
|
178
|
+
sessionId: `${identity.userId}:${request.targetBotId}`,
|
|
179
|
+
acceptedAt: new Date().toISOString(),
|
|
180
|
+
text: request.message,
|
|
181
|
+
source: {
|
|
182
|
+
kind: "bot",
|
|
183
|
+
fromBotId: identity.botId,
|
|
184
|
+
fromBotName: turn.fromBotName,
|
|
185
|
+
messageId: runId,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
targetBotId: request.targetBotId,
|
|
191
|
+
targetBotName: target.initialName,
|
|
192
|
+
runId,
|
|
193
|
+
text: turnResult.text,
|
|
194
|
+
};
|
|
195
|
+
} finally {
|
|
196
|
+
await authorities.releaseAgentTurn({
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
userId: identity.userId,
|
|
199
|
+
requesterId: identity.botId,
|
|
200
|
+
runId,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
},
|
|
95
204
|
};
|
|
96
205
|
}
|
package/src/backend-runner.ts
CHANGED