@frockbot/plugin-shell 0.3.10 → 0.3.12
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 +34 -32
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +107 -2
- package/src/backend-configuration.test.ts +15 -9
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +118 -27
- package/src/client/FrockBotApp.vue +405 -134
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +134 -89
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/notification-id.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +148 -38
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +17 -2
- package/src/shared.ts +36 -0
- package/src/unread.ts +23 -1
- package/tsconfig.json +1 -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.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
|
|
28
28
|
"./client/notify": "./src/client/notify.ts",
|
|
29
29
|
"./client/styles.css": "./src/client/styles.css",
|
|
30
|
+
"./focus": "./src/focus.ts",
|
|
30
31
|
"./frockbot.json": "./frockbot.json",
|
|
31
32
|
"./host": "./src/host.ts",
|
|
32
33
|
"./manifest": "./src/manifest.ts",
|
|
@@ -48,45 +49,46 @@
|
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
51
|
-
"@frockbot/agent-runtime": "0.3.
|
|
52
|
-
"@frockbot/application-foundation": "0.3.
|
|
53
|
-
"@frockbot/catalog-core": "0.3.
|
|
54
|
-
"@frockbot/client-core": "0.3.
|
|
55
|
-
"@frockbot/client-ui": "0.3.
|
|
56
|
-
"@frockbot/computer-core": "0.3.
|
|
57
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
58
|
-
"@frockbot/configuration-core": "0.3.
|
|
59
|
-
"@frockbot/connection-core": "0.3.
|
|
60
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
61
|
-
"@frockbot/kernel-composition": "0.3.
|
|
62
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
63
|
-
"@frockbot/kernel-do": "0.3.
|
|
64
|
-
"@frockbot/machine-protocol": "0.3.
|
|
65
|
-
"@frockbot/plugin-applets": "0.3.
|
|
66
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
67
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
68
|
-
"@frockbot/plugin-computer": "0.3.
|
|
69
|
-
"@frockbot/plugin-flock": "0.3.
|
|
70
|
-
"@frockbot/plugin-image": "0.3.
|
|
71
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
72
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
73
|
-
"@frockbot/plugin-memory": "0.3.
|
|
74
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
75
|
-
"@frockbot/plugin-routines": "0.3.
|
|
76
|
-
"@frockbot/plugin-skills": "0.3.
|
|
77
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
78
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
79
|
-
"@frockbot/protocol": "0.3.
|
|
52
|
+
"@frockbot/agent-runtime": "0.3.12",
|
|
53
|
+
"@frockbot/application-foundation": "0.3.12",
|
|
54
|
+
"@frockbot/catalog-core": "0.3.12",
|
|
55
|
+
"@frockbot/client-core": "0.3.12",
|
|
56
|
+
"@frockbot/client-ui": "0.3.12",
|
|
57
|
+
"@frockbot/computer-core": "0.3.12",
|
|
58
|
+
"@frockbot/computer-host-protocol": "0.3.12",
|
|
59
|
+
"@frockbot/configuration-core": "0.3.12",
|
|
60
|
+
"@frockbot/connection-core": "0.3.12",
|
|
61
|
+
"@frockbot/kernel-agent-loop": "0.3.12",
|
|
62
|
+
"@frockbot/kernel-composition": "0.3.12",
|
|
63
|
+
"@frockbot/kernel-contracts": "0.3.12",
|
|
64
|
+
"@frockbot/kernel-do": "0.3.12",
|
|
65
|
+
"@frockbot/machine-protocol": "0.3.12",
|
|
66
|
+
"@frockbot/plugin-applets": "0.3.12",
|
|
67
|
+
"@frockbot/plugin-authoring": "0.3.12",
|
|
68
|
+
"@frockbot/plugin-bot-template": "0.3.12",
|
|
69
|
+
"@frockbot/plugin-computer": "0.3.12",
|
|
70
|
+
"@frockbot/plugin-flock": "0.3.12",
|
|
71
|
+
"@frockbot/plugin-image": "0.3.12",
|
|
72
|
+
"@frockbot/plugin-machine-messages": "0.3.12",
|
|
73
|
+
"@frockbot/plugin-mcp": "0.3.12",
|
|
74
|
+
"@frockbot/plugin-memory": "0.3.12",
|
|
75
|
+
"@frockbot/plugin-package-catalog": "0.3.12",
|
|
76
|
+
"@frockbot/plugin-routines": "0.3.12",
|
|
77
|
+
"@frockbot/plugin-skills": "0.3.12",
|
|
78
|
+
"@frockbot/plugin-subagents": "0.3.12",
|
|
79
|
+
"@frockbot/plugin-user-machine": "0.3.12",
|
|
80
|
+
"@frockbot/protocol": "0.3.12",
|
|
80
81
|
"cordis": "4.0.0-rc.8",
|
|
81
82
|
"vue": "3.5.41"
|
|
82
83
|
},
|
|
83
84
|
"devDependencies": {
|
|
84
85
|
"@cloudflare/workers-types": "latest",
|
|
85
|
-
"@frockbot/plugin-
|
|
86
|
+
"@frockbot/plugin-prompt": "0.3.12",
|
|
87
|
+
"@frockbot/plugin-tools": "0.3.12",
|
|
86
88
|
"@types/bun": "1.3.6",
|
|
87
89
|
"@types/node": "26.2.0",
|
|
88
90
|
"@vitejs/plugin-vue": "6.0.8",
|
|
89
|
-
"typescript": "
|
|
91
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
|
|
90
92
|
"vite": "8.2.2",
|
|
91
93
|
"vue-tsc": "3.3.10"
|
|
92
94
|
},
|
package/src/agent.test.ts
CHANGED
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
type ToolExecutionContext,
|
|
9
9
|
type TurnTypeV1,
|
|
10
10
|
} from "@frockbot/kernel-contracts";
|
|
11
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
11
12
|
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
12
13
|
import { Context } from "cordis";
|
|
13
14
|
import {
|
|
15
|
+
CONVERSATION_PROMPT_SECTION_V1,
|
|
16
|
+
CONVERSATION_PROMPT_TEXT_V1,
|
|
14
17
|
shellAdmissionCeilingV1,
|
|
15
18
|
shellAgentPlugin,
|
|
16
19
|
PARENT_HANDOFF_CAPABILITY_V1,
|
|
@@ -31,6 +34,7 @@ interface Mounted {
|
|
|
31
34
|
async function mount(): Promise<Mounted> {
|
|
32
35
|
const root = new Context();
|
|
33
36
|
await root.plugin(SessionStore);
|
|
37
|
+
await root.plugin(SystemPromptRegistry);
|
|
34
38
|
await root.plugin(ToolRegistry);
|
|
35
39
|
const session = root.sessions.create(SESSION_ID);
|
|
36
40
|
session.appendBatch([
|
|
@@ -382,3 +386,65 @@ describe("wake_parent", () => {
|
|
|
382
386
|
}
|
|
383
387
|
});
|
|
384
388
|
});
|
|
389
|
+
|
|
390
|
+
// The conversational contract is a prompt section and a tool description, and
|
|
391
|
+
// the two have to say the same thing: a model that read one and not the other
|
|
392
|
+
// would have half the rule.
|
|
393
|
+
describe("the conversation prompt section", () => {
|
|
394
|
+
test("is assembled into the system prompt the model reads", async () => {
|
|
395
|
+
const mounted = await mount();
|
|
396
|
+
try {
|
|
397
|
+
const assembled = await mounted.root.systemPrompt.assemble({
|
|
398
|
+
sessionId: SESSION_ID,
|
|
399
|
+
provider: "test",
|
|
400
|
+
model: "test-model",
|
|
401
|
+
turnType: "chat",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const section = assembled.sections.find(
|
|
405
|
+
(candidate) => candidate.id === CONVERSATION_PROMPT_SECTION_V1,
|
|
406
|
+
);
|
|
407
|
+
expect(section?.text).toBe(CONVERSATION_PROMPT_TEXT_V1);
|
|
408
|
+
expect(assembled.text).toContain(CONVERSATION_PROMPT_TEXT_V1);
|
|
409
|
+
} finally {
|
|
410
|
+
await mounted.dispose();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("says acknowledge first, then beats, and never the steps between", () => {
|
|
415
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain("On it.");
|
|
416
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
|
|
417
|
+
"send only on a real beat: the result, a decision only the user can make, or a blocker",
|
|
418
|
+
);
|
|
419
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
|
|
420
|
+
"Never narrate what you are doing",
|
|
421
|
+
);
|
|
422
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
|
|
423
|
+
"Never leave a question or a request hanging",
|
|
424
|
+
);
|
|
425
|
+
// Short enough to be read as guidance rather than skimmed as a document.
|
|
426
|
+
expect(CONVERSATION_PROMPT_TEXT_V1.split("\n").length).toBeLessThanOrEqual(
|
|
427
|
+
12,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("matches what the send tool's own description tells the model", async () => {
|
|
432
|
+
const mounted = await mount();
|
|
433
|
+
try {
|
|
434
|
+
const schema = mounted.root.tools
|
|
435
|
+
.schemas({ turnType: "chat" })
|
|
436
|
+
.find((tool) => tool.name === SEND_TO_USER_TOOL_V1);
|
|
437
|
+
const description = schema?.description ?? "";
|
|
438
|
+
|
|
439
|
+
expect(description).toContain("only way to say anything the user sees");
|
|
440
|
+
// When to call it, not only what it does.
|
|
441
|
+
expect(description).toContain("Call it once, immediately");
|
|
442
|
+
expect(description).toContain("Call it again only on a real");
|
|
443
|
+
expect(description).toContain("narrate a step or a tool");
|
|
444
|
+
expect(description).toContain("never end your Turn leaving the");
|
|
445
|
+
expect(description).toContain("keep it short");
|
|
446
|
+
} finally {
|
|
447
|
+
await mounted.dispose();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
package/src/agent.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Two tools, and no authority of its own:
|
|
5
5
|
//
|
|
6
|
+
// 0. One prompt section, `conversation`: when to speak and when not to. It
|
|
7
|
+
// is contributed beside the tool so the section and the tool description
|
|
8
|
+
// cannot drift into telling the model two different things.
|
|
9
|
+
//
|
|
6
10
|
// 1. `send_to_user` (legacy alias `send_message`) — parity register row 57b.
|
|
7
11
|
// One tool carrying the typed payload union, admitted on chat turns only,
|
|
8
12
|
// recording each send as `send/to-user` on the durable log. Row 57c: a
|
|
@@ -35,7 +39,15 @@ import {
|
|
|
35
39
|
} from "@frockbot/kernel-contracts";
|
|
36
40
|
// Merges the Agent loop's event declarations into the cordis Context type.
|
|
37
41
|
import type {} from "@frockbot/kernel-agent-loop/agent";
|
|
38
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
automationParentPointerV1,
|
|
44
|
+
chatWindowV1,
|
|
45
|
+
CHAT_HISTORY_BUDGET_CHARS_V1,
|
|
46
|
+
turnScopedMessagesV1,
|
|
47
|
+
turnTypesByTurnV1,
|
|
48
|
+
} from "./history.js";
|
|
49
|
+
import { runCompactionV1 } from "./compaction.js";
|
|
50
|
+
import { compactionWorkV1 } from "./compaction-scheduler.js";
|
|
39
51
|
import type { Plugin } from "cordis";
|
|
40
52
|
import manifest from "../frockbot.json" with { type: "json" };
|
|
41
53
|
|
|
@@ -81,6 +93,11 @@ function refusal(reason: string): ToolExecutionResult {
|
|
|
81
93
|
return { content: reason, isError: true };
|
|
82
94
|
}
|
|
83
95
|
|
|
96
|
+
/** Adapts a cordis fiber to the plain disposer this Package's list holds. */
|
|
97
|
+
function disposeFiber(fiber: { dispose(): unknown }): () => void {
|
|
98
|
+
return () => void fiber.dispose();
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
/**
|
|
85
102
|
* The open step a Shell event belongs to. The session log is the
|
|
86
103
|
* reconstruction surface, so a send without its turn and step would not
|
|
@@ -127,8 +144,38 @@ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
|
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The Bot's conversational contract, in the words the model reads.
|
|
149
|
+
*
|
|
150
|
+
* It lives here rather than in a prompt Package because it is the same rule
|
|
151
|
+
* the send tool's own description states: one place to write it, so the
|
|
152
|
+
* section and the tool cannot drift into telling the model two things. The
|
|
153
|
+
* Shell already owns the voice; it owns how the voice is used.
|
|
154
|
+
*/
|
|
155
|
+
export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
|
|
156
|
+
/** Ordered after identity (0), before anything a Package contributes. */
|
|
157
|
+
export const CONVERSATION_PROMPT_ORDER_V1 = 1;
|
|
158
|
+
|
|
159
|
+
export const CONVERSATION_PROMPT_TEXT_V1 = [
|
|
160
|
+
"## Talking to the user",
|
|
161
|
+
"",
|
|
162
|
+
"Everything the user sees is a `send_to_user` call; nothing else reaches them.",
|
|
163
|
+
'When a request will take more than a moment, send one short line first — "On it." or "Looking into that." — then go quiet and work.',
|
|
164
|
+
"After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
|
|
165
|
+
"Never narrate what you are doing, what you are about to do, or which tool you are using.",
|
|
166
|
+
"Never leave a question or a request hanging: before you stop, the user must have the answer, the result, or the reason there isn't one.",
|
|
167
|
+
"When the work is finished, send the result itself, not an account of how you got it.",
|
|
168
|
+
"Keep every message short — a line or two, no preamble and no sign-off.",
|
|
169
|
+
"Don't say the same thing twice.",
|
|
170
|
+
].join("\n");
|
|
171
|
+
|
|
130
172
|
const SEND_TO_USER_DESCRIPTION = [
|
|
131
173
|
"Speak to the user. This is the only way to say anything the user sees.",
|
|
174
|
+
"Call it once, immediately, with one short line when the request will take",
|
|
175
|
+
"more than a moment, then work in silence. Call it again only on a real",
|
|
176
|
+
"beat: the result, a decision only the user can make, or a blocker. Do not",
|
|
177
|
+
"call it to narrate a step or a tool, and never end your Turn leaving the",
|
|
178
|
+
"user's question unanswered. Each call is one message; keep it short.",
|
|
132
179
|
"The payload is one of:",
|
|
133
180
|
'{"type":"text","text":"…"}',
|
|
134
181
|
'{"type":"attachment","url":"https://…","name":"…","mediaType":"…"}',
|
|
@@ -295,6 +342,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
295
342
|
const userVoice = shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1);
|
|
296
343
|
const parentHandoff = shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1);
|
|
297
344
|
const disposers = [
|
|
345
|
+
// The voice and the rules for using it are contributed together, so a
|
|
346
|
+
// Composition that admits the send tool always carries the contract.
|
|
347
|
+
ctx.systemPrompt.register({
|
|
348
|
+
id: CONVERSATION_PROMPT_SECTION_V1,
|
|
349
|
+
order: CONVERSATION_PROMPT_ORDER_V1,
|
|
350
|
+
render: () => CONVERSATION_PROMPT_TEXT_V1,
|
|
351
|
+
}),
|
|
298
352
|
ctx.tools.register(
|
|
299
353
|
createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
|
|
300
354
|
userVoice ? { admissionCeiling: userVoice } : undefined,
|
|
@@ -307,6 +361,57 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
307
361
|
createWakeParentTool(ctx.sessions),
|
|
308
362
|
parentHandoff ? { admissionCeiling: parentHandoff } : undefined,
|
|
309
363
|
),
|
|
364
|
+
// ADR 0030. `ctx.inject` rather than a declared dependency: a host that
|
|
365
|
+
// mounts the Shell without a model still gets its tools and its transcript
|
|
366
|
+
// seam, and simply never compacts. The hook is evaluated after `turn/end`
|
|
367
|
+
// is on the log and flushed — but `agent/turn-stopping` is a hook the loop
|
|
368
|
+
// *awaits* inside its `finally`, so running the summariser here is exactly
|
|
369
|
+
// the latency ADR 0030 says a compaction never costs. It is handed to the
|
|
370
|
+
// detached scheduler instead and this returns at once: the Turn ends, the
|
|
371
|
+
// run settles, the response goes out, and the summariser carries on behind
|
|
372
|
+
// it. Nothing here may throw, and nothing here may wait.
|
|
373
|
+
disposeFiber(
|
|
374
|
+
ctx.inject(["llm"], (scoped) => {
|
|
375
|
+
scoped.on("agent/turn-stopping", async (agent, turn) => {
|
|
376
|
+
const session = agent.session;
|
|
377
|
+
const types = turnTypesByTurnV1(session.events);
|
|
378
|
+
if ((types.get(turn) ?? "chat") !== "chat") return;
|
|
379
|
+
compactionWorkV1(session.id).start(async (signal) => {
|
|
380
|
+
if (signal.aborted) return;
|
|
381
|
+
await runCompactionV1({
|
|
382
|
+
session,
|
|
383
|
+
window: chatWindowV1(session.events, session.deriveMessages()),
|
|
384
|
+
budget: CHAT_HISTORY_BUDGET_CHARS_V1,
|
|
385
|
+
currentTurn: turn,
|
|
386
|
+
newEffectId: () => `compaction-${crypto.randomUUID()}`,
|
|
387
|
+
summarise: async (request) => {
|
|
388
|
+
let text = "";
|
|
389
|
+
// Two deadlines, one call: the compaction's own, and the abort
|
|
390
|
+
// a newly admitted Turn raises when it takes the log back.
|
|
391
|
+
const cancelled = AbortSignal.any([request.signal, signal]);
|
|
392
|
+
for await (const event of scoped.llm.stream(
|
|
393
|
+
{
|
|
394
|
+
requestId: `compaction-${crypto.randomUUID()}`,
|
|
395
|
+
provider: request.provider,
|
|
396
|
+
model: request.model,
|
|
397
|
+
system: request.system,
|
|
398
|
+
messages: request.messages,
|
|
399
|
+
tools: [],
|
|
400
|
+
...(request.modelBinding
|
|
401
|
+
? { modelBinding: request.modelBinding }
|
|
402
|
+
: {}),
|
|
403
|
+
},
|
|
404
|
+
cancelled,
|
|
405
|
+
)) {
|
|
406
|
+
if (event.type === "text-delta") text += event.text;
|
|
407
|
+
}
|
|
408
|
+
return text;
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}),
|
|
414
|
+
),
|
|
310
415
|
// Applied after the rest of the chain, so this Package has the last word on
|
|
311
416
|
// what history a request carries — the one rule the visible transcript
|
|
312
417
|
// rests on.
|
|
@@ -327,6 +432,6 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
327
432
|
for (const dispose of disposers.toReversed()) dispose();
|
|
328
433
|
};
|
|
329
434
|
};
|
|
330
|
-
shellAgentPlugin.inject = ["tools", "sessions"];
|
|
435
|
+
shellAgentPlugin.inject = ["tools", "sessions", "systemPrompt"];
|
|
331
436
|
|
|
332
437
|
export default shellAgentPlugin;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
|
|
9
9
|
import { createShellBotBackendContribution } from "./backend.js";
|
|
10
10
|
import { createIsolateCapabilityHost } from "./backend-isolate.js";
|
|
11
|
+
import { notificationIdV1 } from "./notification-id.js";
|
|
11
12
|
|
|
12
13
|
class MemoryStorage {
|
|
13
14
|
readonly values = new Map<string, unknown>();
|
|
@@ -149,7 +150,7 @@ function configuredUser(): UserSettingsViewV1 {
|
|
|
149
150
|
connectionId: "flock-ai-ambient",
|
|
150
151
|
packageId: "provider-flock-ai",
|
|
151
152
|
connectionTypeId: "flock-ai-account",
|
|
152
|
-
displayName: "
|
|
153
|
+
displayName: "Frock AI",
|
|
153
154
|
state: "ready",
|
|
154
155
|
providerType: "flock-ai",
|
|
155
156
|
generation: "foundation-generation-1",
|
|
@@ -159,7 +160,7 @@ function configuredUser(): UserSettingsViewV1 {
|
|
|
159
160
|
state: "fresh",
|
|
160
161
|
models: [
|
|
161
162
|
{
|
|
162
|
-
providerModelId: "@
|
|
163
|
+
providerModelId: "@frock/auto",
|
|
163
164
|
displayName: "Platform",
|
|
164
165
|
capabilities: {
|
|
165
166
|
tools: true,
|
|
@@ -173,7 +174,7 @@ function configuredUser(): UserSettingsViewV1 {
|
|
|
173
174
|
safeMetadata: {},
|
|
174
175
|
},
|
|
175
176
|
],
|
|
176
|
-
platformModel: model("flock-ai-ambient", "@
|
|
177
|
+
platformModel: model("flock-ai-ambient", "@frock/auto"),
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
180
|
|
|
@@ -193,7 +194,7 @@ function host(storage: MemoryStorage, readUser: () => UserSettingsViewV1) {
|
|
|
193
194
|
},
|
|
194
195
|
MEMORY_FILES: {},
|
|
195
196
|
MEMORY_INDEX: {},
|
|
196
|
-
|
|
197
|
+
FROCK_AI: {
|
|
197
198
|
autoRoute: "flock-auto",
|
|
198
199
|
runChatCompletion: () =>
|
|
199
200
|
Promise.resolve(
|
|
@@ -526,7 +527,7 @@ describe("generic per-Turn model resolution", () => {
|
|
|
526
527
|
connectionId: "flock-ai-ambient",
|
|
527
528
|
packageId: "provider-flock-ai",
|
|
528
529
|
provider: "flock-ai",
|
|
529
|
-
providerModelId: "@
|
|
530
|
+
providerModelId: "@frock/auto",
|
|
530
531
|
connectionGeneration: "foundation-generation-1",
|
|
531
532
|
catalogGeneration: "catalog-1",
|
|
532
533
|
});
|
|
@@ -601,9 +602,14 @@ describe("generic per-Turn model resolution", () => {
|
|
|
601
602
|
await expect(contribution.listNotifications()).resolves.toEqual([
|
|
602
603
|
expect.objectContaining({
|
|
603
604
|
// Colons would fail the acknowledge decoder, so the mint replaces
|
|
604
|
-
// them
|
|
605
|
-
|
|
606
|
-
|
|
605
|
+
// them and folds in a digest of the raw parts; a notification nobody
|
|
606
|
+
// can acknowledge 400s forever.
|
|
607
|
+
notificationId: notificationIdV1(
|
|
608
|
+
"package-connection-unavailable",
|
|
609
|
+
"run-1",
|
|
610
|
+
"bot-authored",
|
|
611
|
+
"flock-ai-ambient",
|
|
612
|
+
),
|
|
607
613
|
title: "Connection unavailable",
|
|
608
614
|
}),
|
|
609
615
|
]);
|
|
@@ -645,7 +651,7 @@ describe("generic per-Turn model resolution", () => {
|
|
|
645
651
|
),
|
|
646
652
|
};
|
|
647
653
|
expect(await contribution.resolveConfiguration(identity)).toMatchObject({
|
|
648
|
-
model: model("flock-ai-ambient", "@
|
|
654
|
+
model: model("flock-ai-ambient", "@frock/auto"),
|
|
649
655
|
});
|
|
650
656
|
expect(await contribution.getSettings(identity)).toMatchObject({
|
|
651
657
|
packageValues: {
|
|
@@ -520,12 +520,9 @@ export function createPackageCatalogHost(
|
|
|
520
520
|
index,
|
|
521
521
|
catalogId: provenance.catalogId,
|
|
522
522
|
});
|
|
523
|
-
// A first-party member pins no bundle, so "the entry still offers
|
|
524
|
-
// what the target generation recorded" is the two hashes agreeing,
|
|
525
|
-
// both present or both absent.
|
|
526
523
|
if (
|
|
527
|
-
!targetEntry ||
|
|
528
|
-
targetEntry.bundle
|
|
524
|
+
!targetEntry?.bundle ||
|
|
525
|
+
targetEntry.bundle.contentHash !== provenance.contentHash
|
|
529
526
|
) {
|
|
530
527
|
return undoRefused(
|
|
531
528
|
request,
|
|
@@ -541,9 +538,7 @@ export function createPackageCatalogHost(
|
|
|
541
538
|
version: targetCatalogMember.version,
|
|
542
539
|
catalogId: provenance.catalogId,
|
|
543
540
|
catalogGeneration: provenance.catalogGeneration,
|
|
544
|
-
|
|
545
|
-
? {}
|
|
546
|
-
: { contentHash: provenance.contentHash }),
|
|
541
|
+
contentHash: provenance.contentHash,
|
|
547
542
|
};
|
|
548
543
|
}
|
|
549
544
|
const receipt = await options.user.execute(command);
|
|
@@ -596,6 +591,13 @@ export function createPackageCatalogHost(
|
|
|
596
591
|
let user: UserSettingsViewV1;
|
|
597
592
|
let base: CompositionGenerationV1;
|
|
598
593
|
let intent: PackageCatalogChangeIntentV1;
|
|
594
|
+
/**
|
|
595
|
+
* The entry names a compiled-in Package that is already required core in
|
|
596
|
+
* this Bot's Composition, so installing it is a User settings change and
|
|
597
|
+
* nothing else. Set on the install path and re-derived on a resumed
|
|
598
|
+
* intent, which carries no bundle hash precisely when this is true.
|
|
599
|
+
*/
|
|
600
|
+
let firstParty = false;
|
|
599
601
|
try {
|
|
600
602
|
user = await options.user.read();
|
|
601
603
|
base = await options.composition.lastKnownGood();
|
|
@@ -616,6 +618,12 @@ export function createPackageCatalogHost(
|
|
|
616
618
|
);
|
|
617
619
|
entry = resumed;
|
|
618
620
|
intent = existingIntent;
|
|
621
|
+
firstParty =
|
|
622
|
+
existingIntent.action !== "remove" &&
|
|
623
|
+
!resumed.bundle &&
|
|
624
|
+
base.members.find(
|
|
625
|
+
(candidate) => candidate.packageId === resumed.packageId,
|
|
626
|
+
)?.provenance.kind === "first-party";
|
|
619
627
|
} else if (action === "remove") {
|
|
620
628
|
const packageId = request.change.input.packageId;
|
|
621
629
|
const member = base.members.find(
|
|
@@ -697,7 +705,27 @@ export function createPackageCatalogHost(
|
|
|
697
705
|
const member = base.members.find(
|
|
698
706
|
(candidate) => candidate.packageId === entry.packageId,
|
|
699
707
|
);
|
|
700
|
-
|
|
708
|
+
// Installing a first-party entry changes the *User's* settings, not
|
|
709
|
+
// the Composition: the compiled-in Package is already required core
|
|
710
|
+
// in the bootstrap generation (every one of them is), and
|
|
711
|
+
// `assertRequiredCoreSet` refuses any proposal that restates a
|
|
712
|
+
// first-party member with catalog provenance. So "already a member"
|
|
713
|
+
// is the normal state here, not a conflict — the guard below is
|
|
714
|
+
// about a *bundle-backed* entry, whose code really would be added
|
|
715
|
+
// twice.
|
|
716
|
+
firstParty =
|
|
717
|
+
!entry.bundle && member?.provenance.kind === "first-party";
|
|
718
|
+
if (firstParty) {
|
|
719
|
+
// The one integrity check left for a member nothing re-hashes at
|
|
720
|
+
// mount: the entry must name the manifest the application actually
|
|
721
|
+
// compiled in, or the Catalog has drifted from this deployment.
|
|
722
|
+
if (member!.manifestHash !== entry.manifestHash) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
`Catalog entry "${entry.catalogId}" names manifest "${entry.manifestHash}", but this deployment ships "${member!.manifestHash}"`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (action === "install" && member && !firstParty) {
|
|
701
729
|
throw new Error(
|
|
702
730
|
`Package "${entry.packageId}" is already in this Bot's Composition`,
|
|
703
731
|
);
|
|
@@ -757,9 +785,12 @@ export function createPackageCatalogHost(
|
|
|
757
785
|
const bundle = entry.bundle;
|
|
758
786
|
await options.storage.put({
|
|
759
787
|
[packageCatalogChangeIntentKey(request.effectId)]: intent,
|
|
760
|
-
// A bundle-less entry
|
|
761
|
-
//
|
|
762
|
-
//
|
|
788
|
+
// A bundle-less entry stores no manifest document, because it adds
|
|
789
|
+
// no Composition member to mount one for: the compiled-in Package is
|
|
790
|
+
// already required core, running the manifest this deployment
|
|
791
|
+
// shipped. The entry's `manifestHash` was checked against that
|
|
792
|
+
// member's above, so a Catalog that has drifted from this deployment
|
|
793
|
+
// is refused rather than silently recorded.
|
|
763
794
|
...(bundle
|
|
764
795
|
? {
|
|
765
796
|
[authorshipManifestKey(intent.manifestHash)]: {
|
|
@@ -805,11 +836,35 @@ export function createPackageCatalogHost(
|
|
|
805
836
|
|
|
806
837
|
const parent =
|
|
807
838
|
(await options.composition.read(intent.baseGenerationId)) ?? base;
|
|
839
|
+
// A first-party entry contributes no member: it is already required core
|
|
840
|
+
// in the bootstrap generation, and restating it with catalog provenance
|
|
841
|
+
// is exactly what `assertRequiredCoreSet` refuses. The install is the
|
|
842
|
+
// User settings command above, which is all the Plugins page does too,
|
|
843
|
+
// so the Composition is left on the generation it was already running.
|
|
844
|
+
if (firstParty) {
|
|
845
|
+
const outcome: PackageCatalogChangeOutcomeRecordV1 = {
|
|
846
|
+
schemaVersion: 1,
|
|
847
|
+
status: "recorded",
|
|
848
|
+
effectId: request.effectId,
|
|
849
|
+
action,
|
|
850
|
+
packageId: intent.packageId,
|
|
851
|
+
displayName: intent.displayName,
|
|
852
|
+
version: intent.version,
|
|
853
|
+
generationId: parent.generationId,
|
|
854
|
+
missingConnectionTypes: intent.missingConnectionTypes,
|
|
855
|
+
recordedAt: intent.recordedAt,
|
|
856
|
+
};
|
|
857
|
+
await options.storage.put({
|
|
858
|
+
[packageCatalogChangeOutcomeKey(request.effectId)]: outcome,
|
|
859
|
+
});
|
|
860
|
+
return recordedOutcome(outcome);
|
|
861
|
+
}
|
|
862
|
+
|
|
808
863
|
const members = parent.members.filter(
|
|
809
864
|
(member) => member.packageId !== intent.packageId,
|
|
810
865
|
);
|
|
811
866
|
if (action !== "remove") {
|
|
812
|
-
const bundle = entry.bundle
|
|
867
|
+
const bundle = entry.bundle!;
|
|
813
868
|
const member: CompositionMemberV1 = {
|
|
814
869
|
packageId: entry.packageId,
|
|
815
870
|
specifier: `catalog:${entry.catalogId}`,
|
|
@@ -821,20 +876,14 @@ export function createPackageCatalogHost(
|
|
|
821
876
|
version: entry.version,
|
|
822
877
|
catalogId: entry.catalogId,
|
|
823
878
|
catalogGeneration: intent.catalogGeneration,
|
|
824
|
-
|
|
879
|
+
contentHash: bundle.contentHash,
|
|
880
|
+
},
|
|
881
|
+
artifact: {
|
|
882
|
+
contentHash: bundle.contentHash,
|
|
883
|
+
size: bundle.size,
|
|
884
|
+
mediaType: bundle.mediaType,
|
|
885
|
+
bundlerVersion: bundle.bundlerVersion,
|
|
825
886
|
},
|
|
826
|
-
// No artifact ⇒ the member mounts in the kernel isolate from the
|
|
827
|
-
// compiled-in Package, exactly as the UI install records it.
|
|
828
|
-
...(bundle
|
|
829
|
-
? {
|
|
830
|
-
artifact: {
|
|
831
|
-
contentHash: bundle.contentHash,
|
|
832
|
-
size: bundle.size,
|
|
833
|
-
mediaType: bundle.mediaType,
|
|
834
|
-
bundlerVersion: bundle.bundlerVersion,
|
|
835
|
-
},
|
|
836
|
-
}
|
|
837
|
-
: {}),
|
|
838
887
|
};
|
|
839
888
|
members.push(member);
|
|
840
889
|
}
|
package/src/backend-runner.ts
CHANGED
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
BotTurnRecoveryRequiredError,
|
|
18
18
|
} from "@frockbot/kernel-do";
|
|
19
19
|
import type { BotTurnCommand, BotTurnCompletion } from "./backend-contracts.js";
|
|
20
|
+
import {
|
|
21
|
+
compactionInFlightV1,
|
|
22
|
+
whenCompactionSettledV1,
|
|
23
|
+
} from "./compaction-scheduler.js";
|
|
20
24
|
|
|
21
25
|
export {
|
|
22
26
|
BotTurnExecutionError,
|
|
@@ -370,11 +374,24 @@ export async function executeBotTurn(
|
|
|
370
374
|
await runtime.agent.agent.whenIdle();
|
|
371
375
|
return settleBotTurn(runtime.agent, command, previousEvents);
|
|
372
376
|
} catch (error) {
|
|
373
|
-
turnExecutionError(error, previousEvents, [
|
|
377
|
+
return turnExecutionError(error, previousEvents, [
|
|
374
378
|
...runtime.agent.agent.session.events,
|
|
375
379
|
]);
|
|
376
380
|
} finally {
|
|
377
|
-
|
|
381
|
+
// ADR 0030: a compaction outlives the Turn that triggered it, and it runs
|
|
382
|
+
// on this Composition's model binding — so the Composition outlives the
|
|
383
|
+
// Turn too, and only by as long as the compaction does. Awaiting the
|
|
384
|
+
// disposal here would put the summariser back in the latency path, which
|
|
385
|
+
// is the whole defect. The next admission aborts anything still running,
|
|
386
|
+
// so this can never stack up.
|
|
387
|
+
if (compactionInFlightV1(command.sessionId)) {
|
|
388
|
+
void whenCompactionSettledV1(command.sessionId).then(
|
|
389
|
+
() => composition.dispose(),
|
|
390
|
+
() => composition.dispose(),
|
|
391
|
+
);
|
|
392
|
+
} else {
|
|
393
|
+
await composition.dispose();
|
|
394
|
+
}
|
|
378
395
|
}
|
|
379
396
|
}
|
|
380
397
|
|