@frockbot/plugin-shell 0.0.0 → 0.1.1
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 +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The redacted hosted-client projection of the Bot's durable Composition
|
|
2
|
+
// generations. The durable record is kernel authority; what a User may see of
|
|
3
|
+
// it is Shell Package policy, so the projection lives here and never lets
|
|
4
|
+
// artifact bytes, manifest hashes, or loader identities cross the seam.
|
|
5
|
+
import {
|
|
6
|
+
decodeCompositionGenerationViewV1,
|
|
7
|
+
MAX_COMPOSITION_FAILURE_PAGE_V1,
|
|
8
|
+
type CompositionGenerationViewV1,
|
|
9
|
+
type CompositionMemberViewV1,
|
|
10
|
+
type CompositionProvenanceViewV1,
|
|
11
|
+
} from "@frockbot/configuration-core";
|
|
12
|
+
import type {
|
|
13
|
+
CompositionFailureV1,
|
|
14
|
+
CompositionQuarantineV1,
|
|
15
|
+
} from "@frockbot/kernel-composition/activation";
|
|
16
|
+
import type {
|
|
17
|
+
CompositionGenerationV1,
|
|
18
|
+
CompositionMemberV1,
|
|
19
|
+
} from "@frockbot/kernel-composition/generation";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SEAM — plan Step 5 (authoring). Once the Bot object holds
|
|
23
|
+
* `authorship:intent:<effectId>` / `artifact:<contentHash>` records, the Shell
|
|
24
|
+
* Contribution supplies a reader that returns the recorded TypeScript source
|
|
25
|
+
* for an isolate member. Until those records exist, no reader is supplied and a
|
|
26
|
+
* generation view carries its member list alone.
|
|
27
|
+
*/
|
|
28
|
+
export type CompositionMemberSourceReaderV1 = (
|
|
29
|
+
member: CompositionMemberV1,
|
|
30
|
+
) => Promise<string | undefined>;
|
|
31
|
+
|
|
32
|
+
function provenanceView(
|
|
33
|
+
member: CompositionMemberV1,
|
|
34
|
+
): CompositionProvenanceViewV1 {
|
|
35
|
+
const provenance = member.provenance;
|
|
36
|
+
if (provenance.kind === "first-party") return { kind: "first-party" };
|
|
37
|
+
if (provenance.kind === "user") {
|
|
38
|
+
return {
|
|
39
|
+
kind: "user",
|
|
40
|
+
userId: provenance.userId,
|
|
41
|
+
authoredAt: provenance.authoredAt,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
kind: "bot",
|
|
46
|
+
botId: provenance.botId,
|
|
47
|
+
sessionId: provenance.sessionId,
|
|
48
|
+
turnId: provenance.turnId,
|
|
49
|
+
runId: provenance.runId,
|
|
50
|
+
authoredAt: provenance.authoredAt,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProjectCompositionGenerationInput {
|
|
55
|
+
botId: string;
|
|
56
|
+
generation: CompositionGenerationV1;
|
|
57
|
+
currentGenerationId: string;
|
|
58
|
+
/** Omitted for the list projection: only a single generation carries source. */
|
|
59
|
+
readMemberSource?: CompositionMemberSourceReaderV1;
|
|
60
|
+
/** Recorded activation failures for this generation, oldest attempt first. */
|
|
61
|
+
failures?: readonly CompositionFailureV1[];
|
|
62
|
+
/** Present once three consecutive failures quarantined this generation. */
|
|
63
|
+
quarantine?: CompositionQuarantineV1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One durable generation as the hosted client may see it. */
|
|
67
|
+
export async function projectCompositionGenerationV1(
|
|
68
|
+
input: ProjectCompositionGenerationInput,
|
|
69
|
+
): Promise<CompositionGenerationViewV1> {
|
|
70
|
+
const members: CompositionMemberViewV1[] = [];
|
|
71
|
+
for (const member of input.generation.members) {
|
|
72
|
+
const source = member.artifact
|
|
73
|
+
? await input.readMemberSource?.(member)
|
|
74
|
+
: undefined;
|
|
75
|
+
members.push({
|
|
76
|
+
packageId: member.packageId,
|
|
77
|
+
version: member.version,
|
|
78
|
+
provenance: provenanceView(member),
|
|
79
|
+
...(member.artifact ? { contentHash: member.artifact.contentHash } : {}),
|
|
80
|
+
...(source === undefined ? {} : { source }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Diagnostics carry artifact content hashes and loader identities, so the
|
|
84
|
+
// view keeps only the repairable half of a failure: when, where, and why.
|
|
85
|
+
const failures = (input.failures ?? [])
|
|
86
|
+
.slice(-MAX_COMPOSITION_FAILURE_PAGE_V1)
|
|
87
|
+
.map((failure) => ({
|
|
88
|
+
attempt: failure.attempt,
|
|
89
|
+
at: failure.at,
|
|
90
|
+
phase: failure.phase,
|
|
91
|
+
message: failure.message,
|
|
92
|
+
}));
|
|
93
|
+
// Decoding the projection is the seam check: a field the view does not
|
|
94
|
+
// declare cannot reach a client through this function.
|
|
95
|
+
return decodeCompositionGenerationViewV1({
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
botId: input.botId,
|
|
98
|
+
generationId: input.generation.generationId,
|
|
99
|
+
createdAt: input.generation.createdAt,
|
|
100
|
+
status: input.generation.status,
|
|
101
|
+
origin: input.generation.origin,
|
|
102
|
+
isCurrent: input.generation.generationId === input.currentGenerationId,
|
|
103
|
+
members,
|
|
104
|
+
failures,
|
|
105
|
+
...(input.quarantine === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: {
|
|
108
|
+
quarantine: {
|
|
109
|
+
quarantinedAt: input.quarantine.quarantinedAt,
|
|
110
|
+
reason: input.quarantine.reason,
|
|
111
|
+
failures: input.quarantine.failures,
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
...(input.generation.parentGenerationId === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { parentGenerationId: input.generation.parentGenerationId }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BOT_DEBUG_RUN_LIMIT_V1,
|
|
4
|
+
boundDebugEventsV1,
|
|
5
|
+
decodeBotDebugQueryV1,
|
|
6
|
+
} from "./debug-protocol.js";
|
|
7
|
+
|
|
8
|
+
describe("debug query", () => {
|
|
9
|
+
test("accepts the empty query", () => {
|
|
10
|
+
expect(decodeBotDebugQueryV1({ schemaVersion: 1 })).toEqual({
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("carries a run lookup, a page bound, a cursor, and an events flag", () => {
|
|
16
|
+
expect(
|
|
17
|
+
decodeBotDebugQueryV1({
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
runId: "run-1",
|
|
20
|
+
limit: 3,
|
|
21
|
+
before: "run-index:2026-08-28T00:00:00.000Z:run-0",
|
|
22
|
+
events: true,
|
|
23
|
+
}),
|
|
24
|
+
).toEqual({
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
runId: "run-1",
|
|
27
|
+
limit: 3,
|
|
28
|
+
before: "run-index:2026-08-28T00:00:00.000Z:run-0",
|
|
29
|
+
events: true,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects an unknown field", () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
decodeBotDebugQueryV1({ schemaVersion: 1, sql: "select 1" }),
|
|
36
|
+
).toThrow("debug query has invalid fields");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("rejects a limit past the page bound", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
decodeBotDebugQueryV1({
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
limit: BOT_DEBUG_RUN_LIMIT_V1 + 1,
|
|
44
|
+
}),
|
|
45
|
+
).toThrow("debug query limit is invalid");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects a wrong schema version", () => {
|
|
49
|
+
expect(() => decodeBotDebugQueryV1({ schemaVersion: 2 })).toThrow(
|
|
50
|
+
"debug query schemaVersion is invalid",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("event bounding", () => {
|
|
56
|
+
test("keeps everything inside the budget", () => {
|
|
57
|
+
const events = [{ seq: 1 }, { seq: 2 }];
|
|
58
|
+
expect(boundDebugEventsV1(events, 1_000)).toMatchObject({
|
|
59
|
+
events,
|
|
60
|
+
omittedEvents: 0,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("drops the oldest events, because a failure is described by the tail", () => {
|
|
65
|
+
const events = [{ seq: 1 }, { seq: 2 }, { seq: 3 }];
|
|
66
|
+
const size = new TextEncoder().encode(JSON.stringify(events[0])).byteLength;
|
|
67
|
+
const bounded = boundDebugEventsV1(events, size * 2);
|
|
68
|
+
|
|
69
|
+
expect(bounded.events).toEqual([{ seq: 2 }, { seq: 3 }]);
|
|
70
|
+
expect(bounded.omittedEvents).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("keeps the newest event even when it alone exceeds the budget", () => {
|
|
74
|
+
const events = [{ seq: 1 }, { seq: 2 }];
|
|
75
|
+
const bounded = boundDebugEventsV1(events, 1);
|
|
76
|
+
|
|
77
|
+
expect(bounded.events).toEqual([{ seq: 2 }]);
|
|
78
|
+
expect(bounded.omittedEvents).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's view of a Bot's durable state: the runs as the authority
|
|
3
|
+
* actually stored them, the Composition generations they pinned, and the
|
|
4
|
+
* failures recorded against those generations.
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately *not* the client run protocol. `run-protocol.ts`
|
|
7
|
+
* projects a transcript for a person reading their conversation: it hides
|
|
8
|
+
* non-visible runs, drops `model/request`, drops tool-call inputs, and
|
|
9
|
+
* collapses anything it cannot show into `run/events-truncated`. Every one of
|
|
10
|
+
* those omissions is the thing an operator needs when the agent loop
|
|
11
|
+
* misbehaves — which prompt went to the model, what the tool was asked to do,
|
|
12
|
+
* why the turn failed, which generation it was admitted under.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here mutates. In particular a debug read never recovers an active
|
|
15
|
+
* run or settles an uncertain effect: a stuck Bot must still be stuck after
|
|
16
|
+
* you have looked at it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const BOT_DEBUG_RUN_LIMIT_V1 = 20;
|
|
20
|
+
export const BOT_DEBUG_DEFAULT_RUN_LIMIT_V1 = 5;
|
|
21
|
+
export const BOT_DEBUG_GENERATION_LIMIT_V1 = 5;
|
|
22
|
+
/**
|
|
23
|
+
* The event budget one snapshot spends. Session events carry whole prompts, so
|
|
24
|
+
* a handful of runs can be megabytes; past this the *oldest* events of a run
|
|
25
|
+
* are dropped, because a failure is described by the tail of its log.
|
|
26
|
+
*/
|
|
27
|
+
export const BOT_DEBUG_EVENT_BYTES_V1 = 512_000;
|
|
28
|
+
|
|
29
|
+
export interface BotDebugQueryV1 {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
/** One run, always with its events. Omitted: the newest page of runs. */
|
|
32
|
+
runId?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
/** A run-index cursor from a previous snapshot's `nextCursor`. */
|
|
35
|
+
before?: string;
|
|
36
|
+
/** Include session events in list mode. Single-run lookups always do. */
|
|
37
|
+
events?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BotDebugRunV1 {
|
|
41
|
+
runId: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
acceptedAt: string;
|
|
44
|
+
status: string;
|
|
45
|
+
phase: string;
|
|
46
|
+
input: string;
|
|
47
|
+
commandFingerprint: string;
|
|
48
|
+
compositionGenerationId: string;
|
|
49
|
+
/** Events the session already held when this run was admitted. */
|
|
50
|
+
previousEventCount: number;
|
|
51
|
+
eventCount: number;
|
|
52
|
+
responseText?: string;
|
|
53
|
+
failure?: string;
|
|
54
|
+
events?: unknown[];
|
|
55
|
+
/** Oldest events dropped to stay inside the snapshot's byte budget. */
|
|
56
|
+
omittedEvents?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BotDebugGenerationV1 {
|
|
60
|
+
generationId: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
status: string;
|
|
63
|
+
origin: string;
|
|
64
|
+
artifactSetHash: string;
|
|
65
|
+
parentGenerationId?: string;
|
|
66
|
+
memberCount: number;
|
|
67
|
+
failures: unknown[];
|
|
68
|
+
quarantined: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BotDebugSnapshotV1 {
|
|
72
|
+
schemaVersion: 1;
|
|
73
|
+
botId: string;
|
|
74
|
+
capturedAt: string;
|
|
75
|
+
/**
|
|
76
|
+
* A run the object still considers in flight. A run that sits here across
|
|
77
|
+
* two snapshots, with its event tail unchanged, is the signature of a Bot
|
|
78
|
+
* that stopped mid-turn.
|
|
79
|
+
*/
|
|
80
|
+
activeRunId?: string;
|
|
81
|
+
composition: {
|
|
82
|
+
currentGenerationId: string;
|
|
83
|
+
currentStatus: string;
|
|
84
|
+
lastKnownGoodGenerationId?: string;
|
|
85
|
+
generations: BotDebugGenerationV1[];
|
|
86
|
+
};
|
|
87
|
+
configuration?: unknown;
|
|
88
|
+
notifications: unknown[];
|
|
89
|
+
runs: BotDebugRunV1[];
|
|
90
|
+
nextCursor?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function boundedString(value: unknown, maximum: number, field: string): string {
|
|
98
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
|
|
99
|
+
throw new Error(`debug query ${field} is invalid`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
105
|
+
if (!isRecord(input)) throw new Error("debug query is invalid");
|
|
106
|
+
if (input.schemaVersion !== 1) {
|
|
107
|
+
throw new Error("debug query schemaVersion is invalid");
|
|
108
|
+
}
|
|
109
|
+
const allowed = new Set([
|
|
110
|
+
"schemaVersion",
|
|
111
|
+
"runId",
|
|
112
|
+
"limit",
|
|
113
|
+
"before",
|
|
114
|
+
"events",
|
|
115
|
+
]);
|
|
116
|
+
if (!Object.keys(input).every((key) => allowed.has(key))) {
|
|
117
|
+
throw new Error("debug query has invalid fields");
|
|
118
|
+
}
|
|
119
|
+
const query: BotDebugQueryV1 = { schemaVersion: 1 };
|
|
120
|
+
if (input.runId !== undefined) {
|
|
121
|
+
query.runId = boundedString(input.runId, 128, "runId");
|
|
122
|
+
}
|
|
123
|
+
if (input.before !== undefined) {
|
|
124
|
+
query.before = boundedString(input.before, 512, "before");
|
|
125
|
+
}
|
|
126
|
+
if (input.limit !== undefined) {
|
|
127
|
+
if (
|
|
128
|
+
!Number.isSafeInteger(input.limit) ||
|
|
129
|
+
(input.limit as number) < 1 ||
|
|
130
|
+
(input.limit as number) > BOT_DEBUG_RUN_LIMIT_V1
|
|
131
|
+
) {
|
|
132
|
+
throw new Error("debug query limit is invalid");
|
|
133
|
+
}
|
|
134
|
+
query.limit = input.limit as number;
|
|
135
|
+
}
|
|
136
|
+
if (input.events !== undefined) {
|
|
137
|
+
if (typeof input.events !== "boolean") {
|
|
138
|
+
throw new Error("debug query events is invalid");
|
|
139
|
+
}
|
|
140
|
+
query.events = input.events;
|
|
141
|
+
}
|
|
142
|
+
return query;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Keeps the newest events that fit in `budget`, reporting how many older ones
|
|
147
|
+
* were dropped. The tail is what describes a failure, so the head is what goes.
|
|
148
|
+
*/
|
|
149
|
+
export function boundDebugEventsV1(
|
|
150
|
+
events: readonly unknown[],
|
|
151
|
+
budget: number,
|
|
152
|
+
): { events: unknown[]; omittedEvents: number; spent: number } {
|
|
153
|
+
const encoder = new TextEncoder();
|
|
154
|
+
const kept: unknown[] = [];
|
|
155
|
+
let spent = 0;
|
|
156
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
157
|
+
const size = encoder.encode(JSON.stringify(events[index]) ?? "").byteLength;
|
|
158
|
+
if (spent + size > budget && kept.length > 0) {
|
|
159
|
+
return { events: kept, omittedEvents: index + 1, spent };
|
|
160
|
+
}
|
|
161
|
+
kept.unshift(events[index]);
|
|
162
|
+
spent += size;
|
|
163
|
+
}
|
|
164
|
+
return { events: kept, omittedEvents: 0, spent };
|
|
165
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeSessionEvent,
|
|
4
|
+
type LlmMessage,
|
|
5
|
+
type SessionEvent,
|
|
6
|
+
type SessionEventInput,
|
|
7
|
+
} from "@frockbot/kernel-contracts";
|
|
8
|
+
import {
|
|
9
|
+
automationParentPointerV1,
|
|
10
|
+
currentTurnV1,
|
|
11
|
+
turnScopedMessagesV1,
|
|
12
|
+
turnTypesByTurnV1,
|
|
13
|
+
} from "./history.js";
|
|
14
|
+
|
|
15
|
+
function log(inputs: SessionEventInput[]): SessionEvent[] {
|
|
16
|
+
return inputs.map((input, index) =>
|
|
17
|
+
decodeSessionEvent({
|
|
18
|
+
...input,
|
|
19
|
+
seq: index,
|
|
20
|
+
timestamp: new Date(1_700_000_000_000 + index).toISOString(),
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The same derivation `Session.deriveMessages` performs, over a fixed log. */
|
|
26
|
+
function derive(events: readonly SessionEvent[]): LlmMessage[] {
|
|
27
|
+
const messages: LlmMessage[] = [];
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event.type === "user/message") {
|
|
30
|
+
messages.push({ role: "user", content: event.text });
|
|
31
|
+
} else if (event.type === "assistant/message") {
|
|
32
|
+
messages.push({
|
|
33
|
+
role: "assistant",
|
|
34
|
+
content: event.text,
|
|
35
|
+
toolCalls: event.toolCalls,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return messages;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function turn(
|
|
43
|
+
turnNumber: number,
|
|
44
|
+
turnType: "chat" | "automation",
|
|
45
|
+
text: string,
|
|
46
|
+
reply: string,
|
|
47
|
+
): SessionEventInput[] {
|
|
48
|
+
return [
|
|
49
|
+
{ type: "turn/start", turn: turnNumber },
|
|
50
|
+
{ type: "turn/admission", turn: turnNumber, turnType },
|
|
51
|
+
{ type: "step/start", turn: turnNumber, step: 1 },
|
|
52
|
+
{
|
|
53
|
+
type: "user/message",
|
|
54
|
+
turn: turnNumber,
|
|
55
|
+
step: 1,
|
|
56
|
+
messageId: `m-${turnNumber}`,
|
|
57
|
+
text,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "assistant/message",
|
|
61
|
+
turn: turnNumber,
|
|
62
|
+
step: 1,
|
|
63
|
+
requestId: `r-${turnNumber}`,
|
|
64
|
+
text: reply,
|
|
65
|
+
toolCalls: [],
|
|
66
|
+
},
|
|
67
|
+
{ type: "step/end", turn: turnNumber, step: 1, outcome: "completed" },
|
|
68
|
+
{ type: "turn/end", turn: turnNumber, outcome: "completed" },
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scoped(events: SessionEvent[]): LlmMessage[] {
|
|
73
|
+
return turnScopedMessagesV1({
|
|
74
|
+
events,
|
|
75
|
+
messages: derive(events),
|
|
76
|
+
pointer: automationParentPointerV1,
|
|
77
|
+
sessionId: "bot:scout",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("turn-scoped prompt history", () => {
|
|
82
|
+
test("a chat Turn sees only the Turns admitted as chat", () => {
|
|
83
|
+
const events = log([
|
|
84
|
+
...turn(1, "chat", "morning", "hello"),
|
|
85
|
+
...turn(2, "automation", "Routine fired", "checked the inbox"),
|
|
86
|
+
...turn(3, "chat", "anything new?", ""),
|
|
87
|
+
]);
|
|
88
|
+
expect(scoped(events).map((message) => message.content)).toEqual([
|
|
89
|
+
"morning",
|
|
90
|
+
"hello",
|
|
91
|
+
"anything new?",
|
|
92
|
+
"",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an automation Turn starts from a pointer and its own Turn only", () => {
|
|
97
|
+
const events = log([
|
|
98
|
+
...turn(1, "chat", "morning", "hello"),
|
|
99
|
+
...turn(2, "chat", "and again", "hi"),
|
|
100
|
+
{ type: "turn/start", turn: 3 },
|
|
101
|
+
{ type: "turn/admission", turn: 3, turnType: "automation" },
|
|
102
|
+
{ type: "step/start", turn: 3, step: 1 },
|
|
103
|
+
{
|
|
104
|
+
type: "user/message",
|
|
105
|
+
turn: 3,
|
|
106
|
+
step: 1,
|
|
107
|
+
messageId: "m-3",
|
|
108
|
+
text: "Routine fired",
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
const messages = scoped(events);
|
|
112
|
+
expect(messages).toHaveLength(2);
|
|
113
|
+
expect(messages[0]!.content).toContain('session "bot:scout"');
|
|
114
|
+
expect(messages[0]!.content).toContain("2 conversational Turns");
|
|
115
|
+
expect(messages[0]!.content).toContain("wake_parent");
|
|
116
|
+
expect(messages[0]!.content).not.toContain("morning");
|
|
117
|
+
expect(messages[1]!.content).toBe("Routine fired");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a Turn recorded before turn admission existed replays as chat", () => {
|
|
121
|
+
const events = log([
|
|
122
|
+
{ type: "turn/start", turn: 1 },
|
|
123
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
124
|
+
{
|
|
125
|
+
type: "user/message",
|
|
126
|
+
turn: 1,
|
|
127
|
+
step: 1,
|
|
128
|
+
messageId: "m-1",
|
|
129
|
+
text: "legacy",
|
|
130
|
+
},
|
|
131
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
132
|
+
{ type: "turn/end", turn: 1, outcome: "completed" },
|
|
133
|
+
...turn(2, "chat", "now", "then"),
|
|
134
|
+
]);
|
|
135
|
+
expect(scoped(events).map((message) => message.content)).toEqual([
|
|
136
|
+
"legacy",
|
|
137
|
+
"now",
|
|
138
|
+
"then",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("the admission markers and the open turn are read off the log", () => {
|
|
143
|
+
const events = log([
|
|
144
|
+
...turn(1, "chat", "one", "two"),
|
|
145
|
+
{ type: "turn/start", turn: 2 },
|
|
146
|
+
{ type: "turn/admission", turn: 2, turnType: "automation" },
|
|
147
|
+
]);
|
|
148
|
+
expect(turnTypesByTurnV1(events).get(2)).toBe("automation");
|
|
149
|
+
expect(currentTurnV1(events)).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a derivation that disagrees with the log is a visible failure", () => {
|
|
153
|
+
const events = log(turn(1, "chat", "one", "two"));
|
|
154
|
+
expect(() =>
|
|
155
|
+
turnScopedMessagesV1({
|
|
156
|
+
events,
|
|
157
|
+
messages: [],
|
|
158
|
+
pointer: automationParentPointerV1,
|
|
159
|
+
sessionId: "bot:scout",
|
|
160
|
+
}),
|
|
161
|
+
).toThrow("disagree about their length");
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What history a Turn's model request is allowed to see.
|
|
2
|
+
//
|
|
3
|
+
// The Bot Durable Object keeps one ordered event log per Bot — the kernel's
|
|
4
|
+
// reconstruction surface, and not something a Package may partition. But "what
|
|
5
|
+
// enters a model request is Package policy", and this is that policy:
|
|
6
|
+
//
|
|
7
|
+
// 1. **A chat Turn sees only chat Turns.** Every event belonging to a Turn
|
|
8
|
+
// whose `turn/admission` records a turn type other than `chat` is dropped
|
|
9
|
+
// before the request is assembled. An automation firing therefore cannot
|
|
10
|
+
// mutate the visible conversation even though its events share the log.
|
|
11
|
+
//
|
|
12
|
+
// 2. **An automation Turn starts fresh.** It sees its own Turn and one
|
|
13
|
+
// pointer line naming the parent transcript — GrokBot's "fresh subagent…
|
|
14
|
+
// the parent transcript is a pointer, not copied into the prompt". The
|
|
15
|
+
// parent's messages are never copied, at any length.
|
|
16
|
+
//
|
|
17
|
+
// Memory is deliberately untouched by both rules. "The parent agent's shared
|
|
18
|
+
// durable memories are available": Memory is injected as a prompt section, not
|
|
19
|
+
// as history, so a firing keeps every tier the parent has.
|
|
20
|
+
import {
|
|
21
|
+
currentTurnV1,
|
|
22
|
+
messageTurnsV1,
|
|
23
|
+
type LlmMessage,
|
|
24
|
+
type SessionEvent,
|
|
25
|
+
type TurnTypeV1,
|
|
26
|
+
} from "@frockbot/kernel-contracts";
|
|
27
|
+
|
|
28
|
+
export { currentTurnV1 };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The turn type each Turn was admitted as. A Turn recorded before turn
|
|
32
|
+
* admission existed has no marker and replays as `chat`, which is what it was.
|
|
33
|
+
*/
|
|
34
|
+
export function turnTypesByTurnV1(
|
|
35
|
+
events: readonly SessionEvent[],
|
|
36
|
+
): Map<number, TurnTypeV1> {
|
|
37
|
+
const types = new Map<number, TurnTypeV1>();
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
if (event.type === "turn/admission") types.set(event.turn, event.turnType);
|
|
40
|
+
}
|
|
41
|
+
return types;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pointer an automation Turn is given in place of the transcript.
|
|
46
|
+
*
|
|
47
|
+
* It names the conversation and says plainly that nothing from it is here, so
|
|
48
|
+
* the model does not infer that the parent had nothing to say. `wake_parent` is
|
|
49
|
+
* named because it is the only way back.
|
|
50
|
+
*/
|
|
51
|
+
export function automationParentPointerV1(input: {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
chatTurns: number;
|
|
54
|
+
}): string {
|
|
55
|
+
return [
|
|
56
|
+
"You are running as an automation Turn, not inside the conversation with your user.",
|
|
57
|
+
`The parent conversation is session "${input.sessionId}"; it has ${input.chatTurns} conversational ${
|
|
58
|
+
input.chatTurns === 1 ? "Turn" : "Turns"
|
|
59
|
+
} so far, and none of it has been copied into this prompt.`,
|
|
60
|
+
"Your shared durable memories, Skills and work tools are the parent's.",
|
|
61
|
+
"You cannot speak to the user from here. Call `wake_parent` with a complete hand-off when you are done; that message is the only thing the parent will see.",
|
|
62
|
+
].join(" ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TurnScopedMessagesInputV1 {
|
|
66
|
+
events: readonly SessionEvent[];
|
|
67
|
+
messages: readonly LlmMessage[];
|
|
68
|
+
/** The parent-transcript pointer, used only on a non-chat Turn. */
|
|
69
|
+
pointer(input: { sessionId: string; chatTurns: number }): string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The messages one Turn's request may carry, given the whole session log and
|
|
75
|
+
* the messages derived from it.
|
|
76
|
+
*
|
|
77
|
+
* Filtering happens here rather than at derivation because the durable log is
|
|
78
|
+
* one ordered sequence whose contiguity the kernel enforces: the Turn is seeded
|
|
79
|
+
* with the full history so its events keep their sequence, and only the request
|
|
80
|
+
* is narrowed.
|
|
81
|
+
*/
|
|
82
|
+
export function turnScopedMessagesV1(
|
|
83
|
+
input: TurnScopedMessagesInputV1,
|
|
84
|
+
): LlmMessage[] {
|
|
85
|
+
const turns = messageTurnsV1(input.events);
|
|
86
|
+
if (turns.length !== input.messages.length) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"session history and derived messages disagree about their length",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const types = turnTypesByTurnV1(input.events);
|
|
92
|
+
const current = currentTurnV1(input.events);
|
|
93
|
+
const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
|
|
94
|
+
if (chatTurn(current)) {
|
|
95
|
+
return input.messages.filter((_, index) => chatTurn(turns[index]!));
|
|
96
|
+
}
|
|
97
|
+
const own = input.messages.filter((_, index) => turns[index] === current);
|
|
98
|
+
const chatTurns = new Set(
|
|
99
|
+
turns.filter((turn) => turn !== current && chatTurn(turn)),
|
|
100
|
+
).size;
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
role: "user",
|
|
104
|
+
content: input.pointer({ sessionId: input.sessionId, chatTurns }),
|
|
105
|
+
},
|
|
106
|
+
...own,
|
|
107
|
+
];
|
|
108
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Entry } from "@cordisjs/plugin-webui";
|
|
2
|
+
import type { Context } from "cordis";
|
|
3
|
+
import type { FrockBotWebData } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
export function addFrockBotWebEntry(
|
|
6
|
+
ctx: Context,
|
|
7
|
+
data: FrockBotWebData,
|
|
8
|
+
): Entry<FrockBotWebData> {
|
|
9
|
+
const packageUrl = import.meta.resolve("@frockbot/plugin-shell/package.json");
|
|
10
|
+
return ctx.webui.addEntry<FrockBotWebData>(
|
|
11
|
+
{
|
|
12
|
+
modulePath: "@frockbot/plugin-shell",
|
|
13
|
+
baseUrl: packageUrl,
|
|
14
|
+
source: "./src/client/index.ts",
|
|
15
|
+
manifest: "./dist/manifest.json",
|
|
16
|
+
routes: ["/"],
|
|
17
|
+
},
|
|
18
|
+
data,
|
|
19
|
+
);
|
|
20
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED