@frockbot/plugin-shell 0.3.15 → 0.3.17
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 +32 -32
- package/src/agent.test.ts +85 -2
- package/src/agent.ts +87 -20
- package/src/backend-applets.ts +2 -2
- package/src/backend-completion.test.ts +22 -5
- package/src/backend-composition.ts +20 -0
- package/src/backend-configuration.test.ts +7 -5
- package/src/backend-debug.test.ts +49 -0
- package/src/backend-flock.ts +119 -10
- package/src/backend-recovery-integration.test.ts +17 -3
- package/src/backend-runner.ts +1 -0
- package/src/backend.ts +133 -12
- 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/compaction.test.ts +26 -0
- package/src/compaction.ts +46 -14
- package/src/debug-protocol.ts +4 -3
- 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.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -51,42 +51,42 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@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.
|
|
54
|
+
"@frockbot/agent-runtime": "0.3.17",
|
|
55
|
+
"@frockbot/application-foundation": "0.3.17",
|
|
56
|
+
"@frockbot/catalog-core": "0.3.17",
|
|
57
|
+
"@frockbot/client-core": "0.3.17",
|
|
58
|
+
"@frockbot/client-ui": "0.3.17",
|
|
59
|
+
"@frockbot/computer-core": "0.3.17",
|
|
60
|
+
"@frockbot/computer-host-protocol": "0.3.17",
|
|
61
|
+
"@frockbot/configuration-core": "0.3.17",
|
|
62
|
+
"@frockbot/connection-core": "0.3.17",
|
|
63
|
+
"@frockbot/kernel-agent-loop": "0.3.17",
|
|
64
|
+
"@frockbot/kernel-composition": "0.3.17",
|
|
65
|
+
"@frockbot/kernel-contracts": "0.3.17",
|
|
66
|
+
"@frockbot/kernel-do": "0.3.17",
|
|
67
|
+
"@frockbot/machine-protocol": "0.3.17",
|
|
68
|
+
"@frockbot/plugin-applets": "0.3.17",
|
|
69
|
+
"@frockbot/plugin-authoring": "0.3.17",
|
|
70
|
+
"@frockbot/plugin-bot-template": "0.3.17",
|
|
71
|
+
"@frockbot/plugin-computer": "0.3.17",
|
|
72
|
+
"@frockbot/plugin-flock": "0.3.17",
|
|
73
|
+
"@frockbot/plugin-image": "0.3.17",
|
|
74
|
+
"@frockbot/plugin-machine-messages": "0.3.17",
|
|
75
|
+
"@frockbot/plugin-mcp": "0.3.17",
|
|
76
|
+
"@frockbot/plugin-memory": "0.3.17",
|
|
77
|
+
"@frockbot/plugin-package-catalog": "0.3.17",
|
|
78
|
+
"@frockbot/plugin-routines": "0.3.17",
|
|
79
|
+
"@frockbot/plugin-skills": "0.3.17",
|
|
80
|
+
"@frockbot/plugin-subagents": "0.3.17",
|
|
81
|
+
"@frockbot/plugin-user-machine": "0.3.17",
|
|
82
|
+
"@frockbot/protocol": "0.3.17",
|
|
83
83
|
"cordis": "4.0.0-rc.8",
|
|
84
84
|
"vue": "3.5.41"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@cloudflare/workers-types": "latest",
|
|
88
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
89
|
-
"@frockbot/plugin-tools": "0.3.
|
|
88
|
+
"@frockbot/plugin-prompt": "0.3.17",
|
|
89
|
+
"@frockbot/plugin-tools": "0.3.17",
|
|
90
90
|
"@types/bun": "1.3.6",
|
|
91
91
|
"@types/node": "26.2.0",
|
|
92
92
|
"@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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { type SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
3
|
import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
|
|
4
|
+
import { SessionEventLog } from "@frockbot/kernel-do";
|
|
4
5
|
import {
|
|
5
6
|
botTurnCommandFingerprintV1,
|
|
6
7
|
type BotTurnCompletion,
|
|
@@ -81,11 +82,15 @@ class MemoryRunStorage implements RunTerminalStorage {
|
|
|
81
82
|
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
put(
|
|
85
|
-
|
|
86
|
-
if (this.putFailure) return Promise.reject(this.putFailure);
|
|
87
|
-
for (const [key, value] of Object.entries(entries)) {
|
|
85
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
86
|
+
if (typeof key === "string") {
|
|
88
87
|
this.values.set(key, structuredClone(value));
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
}
|
|
90
|
+
this.putBatches.push(structuredClone(key));
|
|
91
|
+
if (this.putFailure) return Promise.reject(this.putFailure);
|
|
92
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
93
|
+
this.values.set(entry, structuredClone(item));
|
|
89
94
|
}
|
|
90
95
|
return Promise.resolve();
|
|
91
96
|
}
|
|
@@ -93,6 +98,16 @@ class MemoryRunStorage implements RunTerminalStorage {
|
|
|
93
98
|
delete(key: string): Promise<boolean> {
|
|
94
99
|
return Promise.resolve(this.values.delete(key));
|
|
95
100
|
}
|
|
101
|
+
|
|
102
|
+
list<T>(options: { prefix: string }): Promise<Map<string, T>> {
|
|
103
|
+
return Promise.resolve(
|
|
104
|
+
new Map(
|
|
105
|
+
[...this.values.entries()].filter(([key]) =>
|
|
106
|
+
key.startsWith(options.prefix),
|
|
107
|
+
) as Array<[string, T]>,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
describe("Bot run terminal persistence", () => {
|
|
@@ -284,6 +299,8 @@ describe("Bot run terminal persistence", () => {
|
|
|
284
299
|
failure: "provider-bound retrieval unavailable",
|
|
285
300
|
});
|
|
286
301
|
expect(storage.values.get(keys.activeRun)).toBe("run-1");
|
|
287
|
-
expect(storage.
|
|
302
|
+
expect(await new SessionEventLog(storage).read("user:primary")).toEqual([
|
|
303
|
+
request,
|
|
304
|
+
]);
|
|
288
305
|
});
|
|
289
306
|
});
|
|
@@ -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();
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
UserSettingsViewV1,
|
|
7
7
|
} from "@frockbot/configuration-core";
|
|
8
8
|
import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
|
|
9
|
+
import { SessionEventLog } from "@frockbot/kernel-do";
|
|
9
10
|
import { createShellBotBackendContribution } from "./backend.js";
|
|
10
11
|
import { createIsolateCapabilityHost } from "./backend-isolate.js";
|
|
11
12
|
import { notificationIdV1 } from "./notification-id.js";
|
|
@@ -549,11 +550,12 @@ describe("generic per-Turn model resolution", () => {
|
|
|
549
550
|
});
|
|
550
551
|
|
|
551
552
|
expect(result.text).toBe("Cordis runtime: hello");
|
|
552
|
-
const durableEvents = storage.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
expect(durableEvents
|
|
556
|
-
|
|
553
|
+
const durableEvents = await new SessionEventLog(storage).read(
|
|
554
|
+
"user-1:primary",
|
|
555
|
+
);
|
|
556
|
+
expect(durableEvents.length).toBeGreaterThan(0);
|
|
557
|
+
expect(durableEvents.map((event) => event.seq)).toEqual(
|
|
558
|
+
durableEvents.map((_, index) => index),
|
|
557
559
|
);
|
|
558
560
|
const settings = await contribution.getSettings(identity);
|
|
559
561
|
expect(settings).toMatchObject({ revision: 0, packageValues: {} });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
|
|
3
|
+
import { SessionEventLog, storedRunRecordV2 } from "@frockbot/kernel-do";
|
|
3
4
|
import { createShellBotBackendContribution } from "./backend.js";
|
|
4
5
|
import type { StoredRun } from "./backend-contracts.js";
|
|
5
6
|
|
|
@@ -154,6 +155,54 @@ describe("Bot debug snapshot", () => {
|
|
|
154
155
|
expect(snapshot.activeRunId).toBeUndefined();
|
|
155
156
|
});
|
|
156
157
|
|
|
158
|
+
test("reads bounded projections instead of hydrating full model prompts", async () => {
|
|
159
|
+
const storage = new MemoryStorage();
|
|
160
|
+
const event = {
|
|
161
|
+
type: "model/request",
|
|
162
|
+
turn: 1,
|
|
163
|
+
step: 1,
|
|
164
|
+
request: {
|
|
165
|
+
requestId: "request-large",
|
|
166
|
+
provider: "fake",
|
|
167
|
+
model: "large-context",
|
|
168
|
+
system: "s".repeat(80_000),
|
|
169
|
+
messages: [{ role: "user", content: "last question" }],
|
|
170
|
+
tools: [],
|
|
171
|
+
},
|
|
172
|
+
seq: 0,
|
|
173
|
+
timestamp: "2026-08-28T00:00:01.000Z",
|
|
174
|
+
} as StoredRun["events"][number];
|
|
175
|
+
const run = storedRun({ events: [event] });
|
|
176
|
+
await new SessionEventLog(storage).rewrite(run.sessionId, [event]);
|
|
177
|
+
await storage.put({
|
|
178
|
+
identity: IDENTITY,
|
|
179
|
+
[`run:${run.runId}`]: storedRunRecordV2(run),
|
|
180
|
+
[`run-index:${run.acceptedAt}:${run.runId}`]: run.runId,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const snapshot = await contributionOver(storage).debugSnapshot(IDENTITY, {
|
|
184
|
+
schemaVersion: 1,
|
|
185
|
+
runId: run.runId,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(snapshot.runs[0]?.eventCount).toBe(1);
|
|
189
|
+
expect(snapshot.runs[0]?.events?.[0]).toMatchObject({
|
|
190
|
+
type: "model/request",
|
|
191
|
+
cut: { marker: "content-cut", originalBytes: expect.any(Number) },
|
|
192
|
+
request: {
|
|
193
|
+
requestId: "request-large",
|
|
194
|
+
messageCount: 1,
|
|
195
|
+
toolCount: 0,
|
|
196
|
+
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
197
|
+
truncated: true,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
expect(JSON.stringify(snapshot)).not.toContain("s".repeat(10_000));
|
|
201
|
+
expect(
|
|
202
|
+
new TextEncoder().encode(JSON.stringify(snapshot)).byteLength,
|
|
203
|
+
).toBeLessThan(20_000);
|
|
204
|
+
});
|
|
205
|
+
|
|
157
206
|
test("includes an active run older than the page it would otherwise fall off", async () => {
|
|
158
207
|
const storage = new MemoryStorage();
|
|
159
208
|
const stale = storedRun({
|