@sublang/playbook 0.9.0 → 1.3.0
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/README.md +190 -151
- package/package.json +50 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
- package/reference/sdlc/code.playbook/bin/run.js +999 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
- package/reference/sdlc/code.playbook/code.playbook.js +199 -488
- package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1165 -89
- package/slc/optimize.md +92 -0
- package/slc/text2gears.md +255 -7
- package/src/runtime.d.ts +146 -3
- package/src/runtime.ts +201 -2
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2058 -0
- package/src/xstate-playbook-runtime.ts +2792 -0
- package/src/xstate-runtime.d.ts +95 -0
- package/src/xstate-runtime.js +1258 -0
- package/src/xstate-runtime.ts +1816 -0
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
//
|
|
4
|
+
// slc link artifact
|
|
5
|
+
// FSM path: ./captain.fsm.ts
|
|
6
|
+
// Player binding: none (no delegated-player states)
|
|
7
|
+
// Adjudication strategy: LLM-judge per Captain state
|
|
8
|
+
// Boss-event mapping: deterministic ready entry; LLM-judge classification otherwise
|
|
9
|
+
|
|
10
|
+
import PQueue from 'p-queue';
|
|
11
|
+
import { createActor, fromPromise, type ActorRefFrom } from 'xstate';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
captainMachine,
|
|
15
|
+
type CaptainInput,
|
|
16
|
+
type CaptainOutput,
|
|
17
|
+
type EnabledPlaybook,
|
|
18
|
+
type PlaybookInput,
|
|
19
|
+
} from './captain.fsm.js';
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
CaptainCallOptions,
|
|
23
|
+
CaptainResult,
|
|
24
|
+
JsonValue,
|
|
25
|
+
PlaybookCallResult,
|
|
26
|
+
PlaybookPorts,
|
|
27
|
+
PlaybookRuntime,
|
|
28
|
+
PlaybookRuntimeFactory,
|
|
29
|
+
PlaybookRunResult,
|
|
30
|
+
PlaybookSession,
|
|
31
|
+
PlaybookState,
|
|
32
|
+
PlaybookTraceEvent,
|
|
33
|
+
} from '../../../src/runtime.js';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
assertJsonSafe,
|
|
37
|
+
combineAbortSignals,
|
|
38
|
+
createNestedPlaybookBridge,
|
|
39
|
+
normalizeError,
|
|
40
|
+
normalizePlaybookSnapshot,
|
|
41
|
+
snapshotJsonValue,
|
|
42
|
+
snapshotPlaybookSession,
|
|
43
|
+
validateCaptainResult,
|
|
44
|
+
waitForPlaybookQuiescence,
|
|
45
|
+
} from '../../../src/xstate-runtime.js';
|
|
46
|
+
|
|
47
|
+
export type {
|
|
48
|
+
CaptainCallOptions,
|
|
49
|
+
CaptainResult,
|
|
50
|
+
JsonValue,
|
|
51
|
+
NormalizedError,
|
|
52
|
+
PlaybookCallRequest,
|
|
53
|
+
PlaybookCallResult,
|
|
54
|
+
PlaybookCallStart,
|
|
55
|
+
PlaybookPorts,
|
|
56
|
+
PlaybookRunResult,
|
|
57
|
+
PlaybookRuntime,
|
|
58
|
+
PlaybookRuntimeFactory,
|
|
59
|
+
PlaybookSession,
|
|
60
|
+
PlaybookState,
|
|
61
|
+
PlaybookStateValue,
|
|
62
|
+
PlaybookTraceEvent,
|
|
63
|
+
PlayerCallOptions,
|
|
64
|
+
PlayerResult,
|
|
65
|
+
} from '../../../src/runtime.js';
|
|
66
|
+
|
|
67
|
+
export interface PlaybookRuntimeOptions {
|
|
68
|
+
readonly enabledPlaybooks: readonly EnabledPlaybook[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type RootActor = ActorRefFrom<typeof captainMachine>;
|
|
72
|
+
|
|
73
|
+
type BossEvent =
|
|
74
|
+
| { readonly type: 'BOSS_INTENT'; readonly bossIntent: string }
|
|
75
|
+
| {
|
|
76
|
+
readonly type: 'BOSS_INTERRUPT';
|
|
77
|
+
readonly targetId: 'routing';
|
|
78
|
+
readonly bossIntent: string;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
readonly type: 'BOSS_REPLY';
|
|
82
|
+
readonly answer: string;
|
|
83
|
+
readonly questionId?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type BossMapping = BossEvent | { readonly type: 'NO_ACTION' } | undefined;
|
|
87
|
+
|
|
88
|
+
type RuntimeSession = Omit<PlaybookSession, 'ports'> & {
|
|
89
|
+
readonly ports: PlaybookPorts;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const CAPTAIN_OPTIONS: CaptainCallOptions = {
|
|
93
|
+
visibility: 'visible',
|
|
94
|
+
resume: false,
|
|
95
|
+
allowedTools: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const CONTINUATION_PREAMBLE =
|
|
99
|
+
'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
100
|
+
|
|
101
|
+
function assertNonEmptyString(value: unknown, label: string): string {
|
|
102
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
103
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const prototype = Object.getPrototypeOf(value);
|
|
113
|
+
return prototype === Object.prototype || prototype === null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function omitUndefined<T extends Record<string, unknown>>(value: T): JsonValue {
|
|
117
|
+
const copy: Record<string, JsonValue> = {};
|
|
118
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
119
|
+
if (entry !== undefined) {
|
|
120
|
+
assertJsonSafe(entry, key);
|
|
121
|
+
copy[key] = snapshotJsonValue(entry, key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return snapshotJsonValue(copy);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stableJson(value: unknown): string {
|
|
128
|
+
const json = snapshotJsonValue(value);
|
|
129
|
+
return JSON.stringify(sortJson(json));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sortJson(value: JsonValue): JsonValue {
|
|
133
|
+
if (Array.isArray(value)) return Object.freeze(value.map((entry) => sortJson(entry)));
|
|
134
|
+
if (value && typeof value === 'object') {
|
|
135
|
+
const record = value as { readonly [key: string]: JsonValue };
|
|
136
|
+
const sorted: Record<string, JsonValue> = {};
|
|
137
|
+
for (const key of Object.keys(value).sort()) {
|
|
138
|
+
sorted[key] = sortJson(record[key]);
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze(sorted);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function replacePlaceholders(template: string, replacements: ReadonlyMap<string, string>): string {
|
|
146
|
+
return template.replace(/<[^>\n]+>/g, (placeholder) => replacements.get(placeholder) ?? placeholder);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function continuationPrefix(input: {
|
|
150
|
+
readonly pendingBossQuestion?: { readonly question: string };
|
|
151
|
+
readonly bossReply?: string;
|
|
152
|
+
}): string {
|
|
153
|
+
if (!input.pendingBossQuestion || !input.bossReply) return '';
|
|
154
|
+
return [
|
|
155
|
+
CONTINUATION_PREAMBLE,
|
|
156
|
+
'',
|
|
157
|
+
'Boss question:',
|
|
158
|
+
input.pendingBossQuestion.question,
|
|
159
|
+
'',
|
|
160
|
+
'Boss reply:',
|
|
161
|
+
input.bossReply,
|
|
162
|
+
'',
|
|
163
|
+
'',
|
|
164
|
+
].join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function composeCaptainPrompt(input: CaptainInput): string {
|
|
168
|
+
const replacements = new Map<string, string>();
|
|
169
|
+
replacements.set('<boss-intent>', input.bossIntent);
|
|
170
|
+
replacements.set('<enabled-playbooks>', stableJson(input.enabledPlaybooks));
|
|
171
|
+
if (input.remainingPlan !== undefined) {
|
|
172
|
+
replacements.set('<remaining-plan>', stableJson(input.remainingPlan));
|
|
173
|
+
}
|
|
174
|
+
if (input.completedCallResults !== undefined) {
|
|
175
|
+
replacements.set('<completed-call-results>', stableJson(input.completedCallResults));
|
|
176
|
+
}
|
|
177
|
+
return `${continuationPrefix(input)}${replacePlaceholders(input.prompt, replacements)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function composePlayerPrompt(input: {
|
|
181
|
+
readonly prompt: string;
|
|
182
|
+
readonly pendingBossQuestion?: { readonly question: string };
|
|
183
|
+
readonly bossReply?: string;
|
|
184
|
+
}): string {
|
|
185
|
+
return `${continuationPrefix(input)}${input.prompt}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateEnabledPlaybooks(value: readonly EnabledPlaybook[]): readonly EnabledPlaybook[] {
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
throw new TypeError('enabledPlaybooks must be an array');
|
|
191
|
+
}
|
|
192
|
+
const ids = new Set<string>();
|
|
193
|
+
return Object.freeze(
|
|
194
|
+
value.map((entry, index) => {
|
|
195
|
+
if (!isRecord(entry)) {
|
|
196
|
+
throw new TypeError(`enabledPlaybooks[${index}] must be an object`);
|
|
197
|
+
}
|
|
198
|
+
const keys = Object.keys(entry).sort();
|
|
199
|
+
if (keys.join('\0') !== ['command', 'id', 'intent'].join('\0')) {
|
|
200
|
+
throw new TypeError(`enabledPlaybooks[${index}] must contain exactly id, command, and intent`);
|
|
201
|
+
}
|
|
202
|
+
const id = assertNonEmptyString(entry.id, `enabledPlaybooks[${index}].id`);
|
|
203
|
+
const command = assertNonEmptyString(entry.command, `enabledPlaybooks[${index}].command`);
|
|
204
|
+
const intent = assertNonEmptyString(entry.intent, `enabledPlaybooks[${index}].intent`);
|
|
205
|
+
if (ids.has(id)) {
|
|
206
|
+
throw new TypeError(`enabledPlaybooks id ${id} is duplicated`);
|
|
207
|
+
}
|
|
208
|
+
ids.add(id);
|
|
209
|
+
return Object.freeze({ id, command, intent });
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseJsonObjectLoose(text: string): Record<string, unknown> | undefined {
|
|
215
|
+
const source = text;
|
|
216
|
+
for (let start = 0; start < source.length; start += 1) {
|
|
217
|
+
if (source[start] !== '{') continue;
|
|
218
|
+
const bounded = boundedJsonCandidate(source, start);
|
|
219
|
+
const candidates = bounded ? [bounded, bounded.replace(/,\s*([}\]])/g, '$1')] : [repairJsonSuffix(source.slice(start))];
|
|
220
|
+
for (const candidate of candidates) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed: unknown = JSON.parse(candidate);
|
|
223
|
+
if (isRecord(parsed)) return parsed;
|
|
224
|
+
} catch {
|
|
225
|
+
// Try the next candidate at the same object boundary.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function boundedJsonCandidate(source: string, start: number): string | undefined {
|
|
233
|
+
let depth = 0;
|
|
234
|
+
let inString = false;
|
|
235
|
+
let escaped = false;
|
|
236
|
+
for (let index = start; index < source.length; index += 1) {
|
|
237
|
+
const char = source[index];
|
|
238
|
+
if (inString) {
|
|
239
|
+
if (escaped) escaped = false;
|
|
240
|
+
else if (char === '\\') escaped = true;
|
|
241
|
+
else if (char === '"') inString = false;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === '"') inString = true;
|
|
245
|
+
else if (char === '{' || char === '[') depth += 1;
|
|
246
|
+
else if (char === '}' || char === ']') {
|
|
247
|
+
depth -= 1;
|
|
248
|
+
if (depth === 0) return source.slice(start, index + 1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function repairJsonSuffix(source: string): string {
|
|
255
|
+
let repaired = source.replace(/,\s*$/g, '');
|
|
256
|
+
let inString = false;
|
|
257
|
+
let escaped = false;
|
|
258
|
+
const stack: string[] = [];
|
|
259
|
+
for (const char of repaired) {
|
|
260
|
+
if (inString) {
|
|
261
|
+
if (escaped) escaped = false;
|
|
262
|
+
else if (char === '\\') escaped = true;
|
|
263
|
+
else if (char === '"') inString = false;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (char === '"') inString = true;
|
|
267
|
+
else if (char === '{') stack.push('}');
|
|
268
|
+
else if (char === '[') stack.push(']');
|
|
269
|
+
else if (char === '}' || char === ']') stack.pop();
|
|
270
|
+
}
|
|
271
|
+
if (inString) repaired += '"';
|
|
272
|
+
while (stack.length > 0) repaired += stack.pop();
|
|
273
|
+
return repaired.replace(/,\s*([}\]])/g, '$1');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function requiredOutputFields(description: string): readonly string[] {
|
|
277
|
+
const marker = description.match(/Output shall include\s+(.+)$/);
|
|
278
|
+
if (!marker) return [];
|
|
279
|
+
const fields: string[] = [];
|
|
280
|
+
const seen = new Set<string>();
|
|
281
|
+
const regex = /`([^`]+)`/g;
|
|
282
|
+
let match: RegExpExecArray | null;
|
|
283
|
+
while ((match = regex.exec(marker[1])) !== null) {
|
|
284
|
+
const name = match[1].split(':', 1)[0]?.trim();
|
|
285
|
+
if (name && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !seen.has(name)) {
|
|
286
|
+
seen.add(name);
|
|
287
|
+
fields.push(name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return fields;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function makeJudgePrompt(input: CaptainInput, visibleText: string): string {
|
|
294
|
+
return [
|
|
295
|
+
'Adjudicate the direct Captain output for this FSM state.',
|
|
296
|
+
`State id: ${input.stateId}`,
|
|
297
|
+
`Source item: ${input.sourceItem}`,
|
|
298
|
+
'',
|
|
299
|
+
'Visible Captain output:',
|
|
300
|
+
visibleText,
|
|
301
|
+
'',
|
|
302
|
+
'Result keys and descriptions:',
|
|
303
|
+
...Object.entries(input.result).map(([key, description]) => `- ${key}: ${description}`),
|
|
304
|
+
'',
|
|
305
|
+
'Return one JSON object with exactly one declared guard.',
|
|
306
|
+
'For direct Captain question or response guards, do not include question or response; the runtime injects the visible text.',
|
|
307
|
+
].join('\n');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function adjudicateCaptainOutput(input: CaptainInput, visibleText: string, judgeText: string): CaptainOutput {
|
|
311
|
+
const parsed = parseJsonObjectLoose(judgeText);
|
|
312
|
+
if (!parsed) throw new Error('adjudicator reply did not contain a JSON object');
|
|
313
|
+
const guard = parsed.guard;
|
|
314
|
+
if (typeof guard !== 'string' || !(guard in input.result)) {
|
|
315
|
+
throw new Error(`adjudicator selected undeclared guard ${String(guard)}`);
|
|
316
|
+
}
|
|
317
|
+
const allowed = new Set(['guard']);
|
|
318
|
+
for (const field of requiredOutputFields(input.result[guard] ?? '')) {
|
|
319
|
+
if (field !== 'question' && field !== 'response') allowed.add(field);
|
|
320
|
+
}
|
|
321
|
+
for (const key of Object.keys(parsed)) {
|
|
322
|
+
if (!allowed.has(key)) throw new Error(`adjudicator supplied undeclared field ${key}`);
|
|
323
|
+
}
|
|
324
|
+
if (guard === 'question' || guard === 'followUpQuestion' || guard === 'needsBossReply') {
|
|
325
|
+
return { guard, question: visibleText } as CaptainOutput;
|
|
326
|
+
}
|
|
327
|
+
if (guard === 'final') {
|
|
328
|
+
return { guard, response: visibleText };
|
|
329
|
+
}
|
|
330
|
+
if (guard === 'delegation' || guard === 'continuing') {
|
|
331
|
+
const missing = ['remainingPlan', 'nextPlaybookId', 'nextPlaybookInput'].filter((field) => !(field in parsed));
|
|
332
|
+
if (missing.length > 0) {
|
|
333
|
+
throw new Error(`adjudicator omitted required field ${missing.join(', ')}`);
|
|
334
|
+
}
|
|
335
|
+
const remainingPlan = snapshotJsonValue(parsed.remainingPlan, 'remainingPlan');
|
|
336
|
+
if (!Array.isArray(remainingPlan)) throw new Error('adjudicator remainingPlan must be a JSON array');
|
|
337
|
+
return {
|
|
338
|
+
guard,
|
|
339
|
+
remainingPlan,
|
|
340
|
+
nextPlaybookId: assertNonEmptyString(parsed.nextPlaybookId, 'nextPlaybookId'),
|
|
341
|
+
nextPlaybookInput: assertNonEmptyString(parsed.nextPlaybookInput, 'nextPlaybookInput'),
|
|
342
|
+
} as CaptainOutput;
|
|
343
|
+
}
|
|
344
|
+
throw new Error(`adjudicator selected unsupported guard ${guard}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateClassifier(text: string, bossText: string, pendingQuestionId: string | undefined): BossMapping {
|
|
348
|
+
const parsed = parseJsonObjectLoose(text);
|
|
349
|
+
if (!parsed) return undefined;
|
|
350
|
+
const type = parsed.type;
|
|
351
|
+
if (type === 'NO_ACTION') {
|
|
352
|
+
if (Object.keys(parsed).length !== 1) return undefined;
|
|
353
|
+
return { type: 'NO_ACTION' };
|
|
354
|
+
}
|
|
355
|
+
if (type === 'BOSS_INTENT') {
|
|
356
|
+
if (Object.keys(parsed).length !== 1) return undefined;
|
|
357
|
+
return { type: 'BOSS_INTENT', bossIntent: bossText };
|
|
358
|
+
}
|
|
359
|
+
if (type === 'BOSS_INTERRUPT') {
|
|
360
|
+
if (Object.keys(parsed).sort().join('\0') !== ['targetId', 'type'].join('\0')) return undefined;
|
|
361
|
+
if (parsed.targetId !== 'routing') return undefined;
|
|
362
|
+
return { type: 'BOSS_INTERRUPT', targetId: 'routing', bossIntent: bossText };
|
|
363
|
+
}
|
|
364
|
+
if (type === 'BOSS_REPLY') {
|
|
365
|
+
const keys = Object.keys(parsed).sort();
|
|
366
|
+
if (keys.join('\0') !== ['questionId', 'type'].join('\0') && keys.join('\0') !== 'type') return undefined;
|
|
367
|
+
const questionId = parsed.questionId === undefined ? pendingQuestionId : parsed.questionId;
|
|
368
|
+
if (questionId !== pendingQuestionId || typeof questionId !== 'string') return undefined;
|
|
369
|
+
return { type: 'BOSS_REPLY', answer: bossText, questionId };
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function classifierPrompt(text: string, state: PlaybookState, pending: unknown): string {
|
|
375
|
+
return [
|
|
376
|
+
'Classify this Boss message for the Captain playbook FSM.',
|
|
377
|
+
'',
|
|
378
|
+
'Boss message:',
|
|
379
|
+
text,
|
|
380
|
+
'',
|
|
381
|
+
'Current state:',
|
|
382
|
+
stableJson(state),
|
|
383
|
+
'',
|
|
384
|
+
'Pending Boss question:',
|
|
385
|
+
stableJson(pending ?? null),
|
|
386
|
+
'',
|
|
387
|
+
'Return JSON only. Allowed objects are {"type":"BOSS_REPLY","questionId":"routing-or-reassessing"}, {"type":"BOSS_INTERRUPT","targetId":"routing"}, {"type":"BOSS_INTENT"}, or {"type":"NO_ACTION"}.',
|
|
388
|
+
].join('\n');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stateFromSnapshot(actor: RootActor, pendingCall?: { readonly callId: string; readonly playbookId: string; readonly childSessionId: string }): PlaybookState {
|
|
392
|
+
return normalizePlaybookSnapshot(actor.getSnapshot(), { pendingCall });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resultFromState(
|
|
396
|
+
state: PlaybookState,
|
|
397
|
+
output: JsonValue | undefined,
|
|
398
|
+
pendingCall?: { readonly callId: string; readonly playbookId: string; readonly childSessionId: string },
|
|
399
|
+
error?: unknown,
|
|
400
|
+
): PlaybookRunResult {
|
|
401
|
+
if (pendingCall) return { outcome: 'suspended', state, pendingCall };
|
|
402
|
+
if (state.status === 'done') {
|
|
403
|
+
return output === undefined ? { outcome: 'terminal', state } : { outcome: 'terminal', state, output };
|
|
404
|
+
}
|
|
405
|
+
if (state.stateId === 'failed') {
|
|
406
|
+
return error === undefined ? { outcome: 'failed', state } : { outcome: 'failed', state, error: normalizeError(error) };
|
|
407
|
+
}
|
|
408
|
+
return { outcome: 'quiescent', state };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isAbortLikeError(error: unknown): boolean {
|
|
412
|
+
return normalizeError(error).name === 'AbortError';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isSignalAbort(error: unknown, signal: AbortSignal): boolean {
|
|
416
|
+
return signal.aborted && error === signal.reason;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
class CaptainPlaybookRuntime implements PlaybookRuntime {
|
|
420
|
+
private readonly enabledPlaybooks: readonly EnabledPlaybook[];
|
|
421
|
+
private readonly emissionQueue = new PQueue({ concurrency: 1 });
|
|
422
|
+
private readonly captainLane = new PQueue({ concurrency: 1 });
|
|
423
|
+
private session: RuntimeSession | undefined;
|
|
424
|
+
private actor: RootActor | undefined;
|
|
425
|
+
private nestedBridge: ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> | undefined;
|
|
426
|
+
private sequence = 0;
|
|
427
|
+
private turnId = 0;
|
|
428
|
+
private callId = 0;
|
|
429
|
+
private boundaryTurnId: number | undefined;
|
|
430
|
+
private readonly playbookCallTurnIds = new Map<string, number>();
|
|
431
|
+
private activeBoundarySignal: AbortSignal | undefined;
|
|
432
|
+
private activeTurn: Promise<PlaybookRunResult> | undefined;
|
|
433
|
+
private disposing: Promise<void> | undefined;
|
|
434
|
+
private disposed = false;
|
|
435
|
+
private terminallyDisposedBeforeInit = false;
|
|
436
|
+
private disposalTraceEmitted = false;
|
|
437
|
+
private initializing = false;
|
|
438
|
+
private initializationDone: Promise<void> | undefined;
|
|
439
|
+
private resolveInitializationDone: (() => void) | undefined;
|
|
440
|
+
private latchedControlError: unknown;
|
|
441
|
+
private suppressInspection = false;
|
|
442
|
+
private previousState: PlaybookState | undefined;
|
|
443
|
+
|
|
444
|
+
constructor(options: PlaybookRuntimeOptions) {
|
|
445
|
+
this.enabledPlaybooks = validateEnabledPlaybooks(options.enabledPlaybooks);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async init(session: PlaybookSession): Promise<void> {
|
|
449
|
+
if (this.session || this.actor) throw new Error('playbook runtime is already initialized');
|
|
450
|
+
if (this.disposed || this.terminallyDisposedBeforeInit || this.disposing) throw new Error('playbook runtime is disposed');
|
|
451
|
+
this.initializing = true;
|
|
452
|
+
this.initializationDone = new Promise((resolve) => {
|
|
453
|
+
this.resolveInitializationDone = resolve;
|
|
454
|
+
});
|
|
455
|
+
this.disposalTraceEmitted = false;
|
|
456
|
+
let actor: RootActor | undefined;
|
|
457
|
+
let initialState: PlaybookState | undefined;
|
|
458
|
+
try {
|
|
459
|
+
const captured = snapshotPlaybookSession(session);
|
|
460
|
+
this.session = captured;
|
|
461
|
+
this.nestedBridge = this.createBridge(captured);
|
|
462
|
+
actor = this.createActor(captured, this.nestedBridge);
|
|
463
|
+
this.actor = actor;
|
|
464
|
+
initialState = stateFromSnapshot(actor);
|
|
465
|
+
this.previousState = initialState;
|
|
466
|
+
await this.trace('session.started', omitUndefined({ state: initialState, stateId: initialState.stateId }));
|
|
467
|
+
await this.drain();
|
|
468
|
+
actor.start();
|
|
469
|
+
await this.drain();
|
|
470
|
+
} catch (error) {
|
|
471
|
+
this.suppressInspection = true;
|
|
472
|
+
actor?.stop();
|
|
473
|
+
if (initialState && !this.disposalTraceEmitted) await this.bestEffortDisposeTrace(initialState);
|
|
474
|
+
this.session = undefined;
|
|
475
|
+
this.actor = undefined;
|
|
476
|
+
this.nestedBridge = undefined;
|
|
477
|
+
this.sequence = 0;
|
|
478
|
+
this.turnId = 0;
|
|
479
|
+
this.callId = 0;
|
|
480
|
+
this.latchedControlError = undefined;
|
|
481
|
+
this.previousState = undefined;
|
|
482
|
+
this.suppressInspection = false;
|
|
483
|
+
throw error;
|
|
484
|
+
} finally {
|
|
485
|
+
this.initializing = false;
|
|
486
|
+
this.resolveInitializationDone?.();
|
|
487
|
+
this.resolveInitializationDone = undefined;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async handleBossInput(turn: { text: string; signal: AbortSignal }): Promise<PlaybookRunResult> {
|
|
492
|
+
if (this.activeTurn) throw new Error('playbook runtime already has an active boundary');
|
|
493
|
+
if (this.disposing || this.disposed) throw new Error('playbook runtime is disposing');
|
|
494
|
+
const run = this.handleBossInputInner(turn);
|
|
495
|
+
this.activeTurn = run;
|
|
496
|
+
try {
|
|
497
|
+
return await run;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (isSignalAbort(error, turn.signal)) {
|
|
500
|
+
const actor = this.actor;
|
|
501
|
+
const bridge = this.nestedBridge;
|
|
502
|
+
const snapshot = actor
|
|
503
|
+
? await waitForPlaybookQuiescence(actor, { pendingCalls: bridge })
|
|
504
|
+
: undefined;
|
|
505
|
+
const state = snapshot
|
|
506
|
+
? normalizePlaybookSnapshot(snapshot, { pendingCall: bridge?.getPendingCall() })
|
|
507
|
+
: { value: 'failed', activeStateIds: ['failed'], tags: ['playbook.parked'], status: 'active', quiescent: true, stateId: 'failed' } satisfies PlaybookState;
|
|
508
|
+
try {
|
|
509
|
+
await this.drain();
|
|
510
|
+
} catch {
|
|
511
|
+
// The signal-driven abort remains the public outcome.
|
|
512
|
+
}
|
|
513
|
+
return { outcome: 'aborted', state, error: normalizeError(error) };
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
} finally {
|
|
517
|
+
this.activeTurn = undefined;
|
|
518
|
+
const error = this.latchedControlError;
|
|
519
|
+
this.latchedControlError = undefined;
|
|
520
|
+
const aborted = this.activeBoundarySignal?.aborted === true;
|
|
521
|
+
this.activeBoundarySignal = undefined;
|
|
522
|
+
this.boundaryTurnId = undefined;
|
|
523
|
+
if (error && (!aborted || !isAbortLikeError(error))) throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async resumePlaybookCall(input: { callId: string; result: PlaybookCallResult; signal: AbortSignal }): Promise<PlaybookRunResult> {
|
|
528
|
+
if (this.activeTurn) throw new Error('playbook runtime already has an active boundary');
|
|
529
|
+
if (this.disposing || this.disposed) throw new Error('playbook runtime is disposing');
|
|
530
|
+
const run = this.resumePlaybookCallInner(input);
|
|
531
|
+
this.activeTurn = run;
|
|
532
|
+
try {
|
|
533
|
+
return await run;
|
|
534
|
+
} finally {
|
|
535
|
+
this.activeTurn = undefined;
|
|
536
|
+
const error = this.latchedControlError;
|
|
537
|
+
this.latchedControlError = undefined;
|
|
538
|
+
const aborted = this.activeBoundarySignal?.aborted === true;
|
|
539
|
+
this.activeBoundarySignal = undefined;
|
|
540
|
+
this.boundaryTurnId = undefined;
|
|
541
|
+
if (error && (!aborted || !isAbortLikeError(error))) throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
dispose(): Promise<void> {
|
|
546
|
+
if (this.activeTurn) return Promise.reject(new Error('cannot dispose during an active boundary'));
|
|
547
|
+
if (this.disposing) return this.disposing;
|
|
548
|
+
if (!this.initializing && !this.session && !this.actor && !this.disposed) {
|
|
549
|
+
this.terminallyDisposedBeforeInit = true;
|
|
550
|
+
this.disposed = true;
|
|
551
|
+
this.disposing = Promise.resolve();
|
|
552
|
+
return this.disposing;
|
|
553
|
+
}
|
|
554
|
+
this.disposing = this.disposeInner();
|
|
555
|
+
return this.disposing;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async handleBossInputInner(turn: { text: string; signal: AbortSignal }): Promise<PlaybookRunResult> {
|
|
559
|
+
const actor = this.requireActor();
|
|
560
|
+
const nestedBridge = this.requireBridge();
|
|
561
|
+
const currentTurnId = this.nextTurnId();
|
|
562
|
+
this.boundaryTurnId = currentTurnId;
|
|
563
|
+
this.activeBoundarySignal = turn.signal;
|
|
564
|
+
await this.trace('boss.input.received', { text: turn.text }, currentTurnId);
|
|
565
|
+
const state = stateFromSnapshot(actor, nestedBridge.getPendingCall());
|
|
566
|
+
let event: BossMapping;
|
|
567
|
+
if (turn.text.trim().length === 0) {
|
|
568
|
+
const result: PlaybookRunResult = { outcome: 'no-action', state };
|
|
569
|
+
await this.traceSettled(result, currentTurnId);
|
|
570
|
+
await this.drain();
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
if (state.stateId === 'ready' || state.stateId === 'failed') {
|
|
574
|
+
event = { type: 'BOSS_INTENT', bossIntent: turn.text };
|
|
575
|
+
} else {
|
|
576
|
+
try {
|
|
577
|
+
event = await this.classifyBossInput(turn.text, state, turn.signal);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
if (isSignalAbort(error, turn.signal)) {
|
|
580
|
+
const result: PlaybookRunResult = { outcome: 'aborted', state, error: normalizeError(error) };
|
|
581
|
+
await this.traceSettled(result, currentTurnId);
|
|
582
|
+
await this.drain();
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
await this.trace(
|
|
586
|
+
'boss.input.settled',
|
|
587
|
+
omitUndefined({
|
|
588
|
+
outcome: 'no-action',
|
|
589
|
+
state,
|
|
590
|
+
stateId: state.stateId,
|
|
591
|
+
error: normalizeError(error),
|
|
592
|
+
}),
|
|
593
|
+
currentTurnId,
|
|
594
|
+
);
|
|
595
|
+
await this.drain();
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
if (!event) {
|
|
599
|
+
await this.emitStatus('classification was invalid; Boss input was not actionable.', { state });
|
|
600
|
+
const result: PlaybookRunResult = { outcome: 'no-action', state };
|
|
601
|
+
await this.traceSettled(result, currentTurnId);
|
|
602
|
+
await this.drain();
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (event?.type === 'NO_ACTION') {
|
|
607
|
+
const result: PlaybookRunResult = { outcome: 'no-action', state };
|
|
608
|
+
await this.traceSettled(result, currentTurnId);
|
|
609
|
+
await this.drain();
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
if (turn.signal.aborted) {
|
|
613
|
+
const result: PlaybookRunResult = { outcome: 'aborted', state, error: normalizeError(turn.signal.reason) };
|
|
614
|
+
await this.traceSettled(result, currentTurnId);
|
|
615
|
+
await this.drain();
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
if (actor.getSnapshot().status === 'done') {
|
|
619
|
+
this.reconstructActor();
|
|
620
|
+
}
|
|
621
|
+
this.requireActor().send(event);
|
|
622
|
+
const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
|
|
623
|
+
const settledState = normalizePlaybookSnapshot(snapshot, { pendingCall: nestedBridge.getPendingCall() });
|
|
624
|
+
const result = turn.signal.aborted
|
|
625
|
+
? { outcome: 'aborted', state: settledState, error: normalizeError(turn.signal.reason) } satisfies PlaybookRunResult
|
|
626
|
+
: resultFromState(
|
|
627
|
+
settledState,
|
|
628
|
+
this.machineOutput(),
|
|
629
|
+
nestedBridge.getPendingCall(),
|
|
630
|
+
this.latchedControlError,
|
|
631
|
+
);
|
|
632
|
+
await this.traceSettled(result, currentTurnId);
|
|
633
|
+
await this.drain();
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private async resumePlaybookCallInner(input: { callId: string; result: PlaybookCallResult; signal: AbortSignal }): Promise<PlaybookRunResult> {
|
|
638
|
+
const nestedBridge = this.requireBridge();
|
|
639
|
+
this.activeBoundarySignal = input.signal;
|
|
640
|
+
this.boundaryTurnId = this.playbookCallTurnIds.get(input.callId);
|
|
641
|
+
let resumeError: unknown;
|
|
642
|
+
try {
|
|
643
|
+
await nestedBridge.resume(input);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
resumeError = error;
|
|
646
|
+
}
|
|
647
|
+
const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
|
|
648
|
+
const pendingCall = nestedBridge.getPendingCall();
|
|
649
|
+
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
650
|
+
const result = resultFromState(state, this.machineOutput(), pendingCall);
|
|
651
|
+
await this.drain();
|
|
652
|
+
if (resumeError !== undefined) throw resumeError;
|
|
653
|
+
return input.signal.aborted ? { outcome: 'aborted', state, error: normalizeError(input.signal.reason) } : result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async disposeInner(): Promise<void> {
|
|
657
|
+
if (this.disposed) return;
|
|
658
|
+
if (this.initializing) {
|
|
659
|
+
await this.initializationDone;
|
|
660
|
+
}
|
|
661
|
+
const actor = this.actor;
|
|
662
|
+
const bridge = this.nestedBridge;
|
|
663
|
+
const finalState = actor ? stateFromSnapshot(actor, bridge?.getPendingCall()) : undefined;
|
|
664
|
+
let cleanupError: unknown;
|
|
665
|
+
this.suppressInspection = true;
|
|
666
|
+
actor?.stop();
|
|
667
|
+
try {
|
|
668
|
+
await bridge?.dispose();
|
|
669
|
+
} catch (error) {
|
|
670
|
+
cleanupError = error;
|
|
671
|
+
}
|
|
672
|
+
if (this.initializing) {
|
|
673
|
+
try {
|
|
674
|
+
await this.drain();
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (cleanupError === undefined) cleanupError = error;
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
try {
|
|
680
|
+
await this.drain();
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (cleanupError === undefined) cleanupError = error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
this.latchedControlError = undefined;
|
|
686
|
+
if (finalState && !this.disposalTraceEmitted) {
|
|
687
|
+
this.disposalTraceEmitted = true;
|
|
688
|
+
try {
|
|
689
|
+
await this.trace('session.disposed', omitUndefined({ state: finalState, stateId: finalState.stateId }));
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (cleanupError === undefined) cleanupError = error;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
await this.drain();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (cleanupError === undefined) cleanupError = error;
|
|
698
|
+
}
|
|
699
|
+
this.session = undefined;
|
|
700
|
+
this.actor = undefined;
|
|
701
|
+
this.nestedBridge = undefined;
|
|
702
|
+
this.disposed = true;
|
|
703
|
+
if (cleanupError !== undefined) throw cleanupError;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private createActor(session: RuntimeSession, bridge: ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>>): RootActor {
|
|
707
|
+
const provided = captainMachine.provide({
|
|
708
|
+
actors: {
|
|
709
|
+
captain: fromPromise<CaptainOutput, CaptainInput>(async ({ input, signal }) => {
|
|
710
|
+
await this.drain();
|
|
711
|
+
const combined = combineAbortSignals(signal, this.activeBoundarySignal);
|
|
712
|
+
return await this.runCaptainActor(input, combined);
|
|
713
|
+
}),
|
|
714
|
+
playbook: bridge.actorLogic,
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
let rootActor: RootActor;
|
|
718
|
+
rootActor = createActor(provided, {
|
|
719
|
+
input: {
|
|
720
|
+
enabledPlaybooks: this.enabledPlaybooks,
|
|
721
|
+
selfPlaybookId: session.playbookId,
|
|
722
|
+
},
|
|
723
|
+
inspect: (inspectionEvent) => {
|
|
724
|
+
if (this.suppressInspection) return;
|
|
725
|
+
if (inspectionEvent.type !== '@xstate.snapshot') return;
|
|
726
|
+
if (inspectionEvent.actorRef !== rootActor) return;
|
|
727
|
+
try {
|
|
728
|
+
this.enqueueTransition(inspectionEvent.event, rootActor);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
this.latchControlError(error);
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
return rootActor;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private createBridge(session: RuntimeSession): ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> {
|
|
738
|
+
return createNestedPlaybookBridge<PlaybookInput>({
|
|
739
|
+
nextCallId: () => `call-${this.nextCallId()}`,
|
|
740
|
+
getBoundarySignal: () => this.activeBoundarySignal,
|
|
741
|
+
callPlaybook: (request, signal) => session.ports.callPlaybook(request, signal),
|
|
742
|
+
emitStarted: async (event) => {
|
|
743
|
+
const turnId = this.currentTraceTurnId();
|
|
744
|
+
if (turnId !== undefined) this.playbookCallTurnIds.set(event.callId, turnId);
|
|
745
|
+
await this.trace('playbook.call.started', {
|
|
746
|
+
stateId: event.stateId,
|
|
747
|
+
playbookId: event.playbookId,
|
|
748
|
+
text: event.text,
|
|
749
|
+
}, turnId, event.callId);
|
|
750
|
+
},
|
|
751
|
+
emitFinished: async (event) => {
|
|
752
|
+
const turnId = this.playbookCallTurnIds.get(event.callId) ?? this.currentTraceTurnId();
|
|
753
|
+
await this.trace('playbook.call.finished', {
|
|
754
|
+
stateId: event.stateId,
|
|
755
|
+
playbookId: event.playbookId,
|
|
756
|
+
text: event.text,
|
|
757
|
+
result: event.result,
|
|
758
|
+
}, turnId, event.callId);
|
|
759
|
+
this.playbookCallTurnIds.delete(event.callId);
|
|
760
|
+
},
|
|
761
|
+
drain: () => this.drain(),
|
|
762
|
+
bindResumeSignal: (signal) => {
|
|
763
|
+
this.activeBoundarySignal = signal;
|
|
764
|
+
},
|
|
765
|
+
onControlPlaneError: (error) => this.latchControlError(error),
|
|
766
|
+
onBackgroundError: (error) => this.latchControlError(error),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private async runCaptainActor(input: CaptainInput, signal: AbortSignal): Promise<CaptainOutput> {
|
|
771
|
+
try {
|
|
772
|
+
const prompt = composeCaptainPrompt(input);
|
|
773
|
+
const result = await this.callCaptain(input, prompt, signal);
|
|
774
|
+
if (signal.aborted) throw signal.reason;
|
|
775
|
+
if (result.status !== 'ok') {
|
|
776
|
+
throw new Error(result.error ?? `Captain returned ${result.status}`);
|
|
777
|
+
}
|
|
778
|
+
if (!result.finalText) {
|
|
779
|
+
throw new Error('Captain returned ok without finalText');
|
|
780
|
+
}
|
|
781
|
+
const judgePrompt = makeJudgePrompt(input, result.finalText);
|
|
782
|
+
const judgeText = await this.callJudge('captain-output-adjudication', judgePrompt, signal, input.stateId);
|
|
783
|
+
return adjudicateCaptainOutput(input, result.finalText, judgeText);
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (!signal.aborted) this.latchControlError(error);
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private async callCaptain(input: CaptainInput, prompt: string, signal: AbortSignal): Promise<CaptainResult> {
|
|
791
|
+
const callId = `captain-${this.nextCallId()}`;
|
|
792
|
+
const startPayload = {
|
|
793
|
+
stateId: input.stateId,
|
|
794
|
+
sourceItem: input.sourceItem,
|
|
795
|
+
prompt,
|
|
796
|
+
visibility: 'visible',
|
|
797
|
+
resume: false,
|
|
798
|
+
allowedTools: [],
|
|
799
|
+
};
|
|
800
|
+
try {
|
|
801
|
+
await this.trace('captain.call.started', startPayload, this.currentTraceTurnId(), callId);
|
|
802
|
+
} catch (error) {
|
|
803
|
+
await this.tracePreservingError(
|
|
804
|
+
'captain.call.finished',
|
|
805
|
+
{
|
|
806
|
+
...startPayload,
|
|
807
|
+
status: 'error',
|
|
808
|
+
error: normalizeError(error),
|
|
809
|
+
},
|
|
810
|
+
error,
|
|
811
|
+
this.currentTraceTurnId(),
|
|
812
|
+
callId,
|
|
813
|
+
);
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
let result: CaptainResult | undefined;
|
|
817
|
+
let failure: unknown;
|
|
818
|
+
try {
|
|
819
|
+
result = await this.captainLane.add(async () => {
|
|
820
|
+
if (signal.aborted) throw signal.reason;
|
|
821
|
+
const raw = await this.requireSession().ports.callCaptain(prompt, signal, CAPTAIN_OPTIONS);
|
|
822
|
+
if (signal.aborted) throw signal.reason;
|
|
823
|
+
return validateCaptainResult(raw);
|
|
824
|
+
});
|
|
825
|
+
if (result.status !== 'ok') {
|
|
826
|
+
failure = new Error(result.error ?? `Captain returned ${result.status}`);
|
|
827
|
+
} else if (!result.finalText) {
|
|
828
|
+
failure = new Error('Captain returned ok without finalText');
|
|
829
|
+
}
|
|
830
|
+
} catch (error) {
|
|
831
|
+
failure = error;
|
|
832
|
+
}
|
|
833
|
+
const normalized = failure === undefined ? undefined : normalizeError(failure);
|
|
834
|
+
const abortedFailure = failure !== undefined && isSignalAbort(failure, signal);
|
|
835
|
+
const finishPayload = {
|
|
836
|
+
stateId: input.stateId,
|
|
837
|
+
sourceItem: input.sourceItem,
|
|
838
|
+
prompt,
|
|
839
|
+
visibility: 'visible',
|
|
840
|
+
resume: false,
|
|
841
|
+
allowedTools: [],
|
|
842
|
+
status: result?.status ?? (abortedFailure ? 'aborted' : 'error'),
|
|
843
|
+
...(result?.finalText === undefined ? {} : { finalText: result.finalText }),
|
|
844
|
+
...(result?.error === undefined ? {} : { error: result.error }),
|
|
845
|
+
...(normalized === undefined ? {} : { error: normalized }),
|
|
846
|
+
};
|
|
847
|
+
if (failure !== undefined) {
|
|
848
|
+
if (isSignalAbort(failure, signal)) {
|
|
849
|
+
await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
850
|
+
throw failure;
|
|
851
|
+
}
|
|
852
|
+
await this.tracePreservingError('captain.call.finished', finishPayload, failure, this.currentTraceTurnId(), callId);
|
|
853
|
+
throw failure;
|
|
854
|
+
}
|
|
855
|
+
await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
856
|
+
if (failure !== undefined) throw failure;
|
|
857
|
+
if (!result) throw new Error('Captain returned no result');
|
|
858
|
+
return result;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private async callJudge(purpose: string, prompt: string, signal: AbortSignal, stateId?: string): Promise<string> {
|
|
862
|
+
const callId = `judge-${this.nextCallId()}`;
|
|
863
|
+
const startPayload = omitUndefined({ purpose, prompt, stateId });
|
|
864
|
+
try {
|
|
865
|
+
await this.trace('judge.call.started', startPayload, this.currentTraceTurnId(), callId);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
await this.tracePreservingError(
|
|
868
|
+
'judge.call.finished',
|
|
869
|
+
omitUndefined({ purpose, prompt, stateId, status: 'error', error: normalizeError(error) }),
|
|
870
|
+
error,
|
|
871
|
+
this.currentTraceTurnId(),
|
|
872
|
+
callId,
|
|
873
|
+
);
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
let reply: string | undefined;
|
|
877
|
+
let failure: unknown;
|
|
878
|
+
try {
|
|
879
|
+
reply = await this.captainLane.add(async () => {
|
|
880
|
+
if (signal.aborted) throw signal.reason;
|
|
881
|
+
const text = await this.requireSession().ports.callJudge(prompt, signal);
|
|
882
|
+
if (signal.aborted) throw signal.reason;
|
|
883
|
+
if (typeof text !== 'string') throw new TypeError('judge reply must be a string');
|
|
884
|
+
return text;
|
|
885
|
+
});
|
|
886
|
+
} catch (error) {
|
|
887
|
+
failure = error;
|
|
888
|
+
}
|
|
889
|
+
if (failure !== undefined) {
|
|
890
|
+
const aborted = isSignalAbort(failure, signal);
|
|
891
|
+
const finishPayload = omitUndefined({
|
|
892
|
+
purpose,
|
|
893
|
+
prompt,
|
|
894
|
+
stateId,
|
|
895
|
+
status: aborted ? 'aborted' : 'error',
|
|
896
|
+
error: normalizeError(failure),
|
|
897
|
+
});
|
|
898
|
+
if (aborted) {
|
|
899
|
+
await this.trace('judge.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
900
|
+
} else {
|
|
901
|
+
await this.tracePreservingError(
|
|
902
|
+
'judge.call.finished',
|
|
903
|
+
finishPayload,
|
|
904
|
+
failure,
|
|
905
|
+
this.currentTraceTurnId(),
|
|
906
|
+
callId,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
throw failure;
|
|
910
|
+
}
|
|
911
|
+
await this.trace(
|
|
912
|
+
'judge.call.finished',
|
|
913
|
+
omitUndefined({ purpose, prompt, stateId, status: 'ok', reply }),
|
|
914
|
+
this.currentTraceTurnId(),
|
|
915
|
+
callId,
|
|
916
|
+
);
|
|
917
|
+
if (reply === undefined) throw new Error('judge returned no reply');
|
|
918
|
+
return reply;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
private async classifyBossInput(text: string, state: PlaybookState, signal: AbortSignal): Promise<BossMapping> {
|
|
922
|
+
const pending = this.pendingQuestion();
|
|
923
|
+
const prompt = classifierPrompt(text, state, pending ? { questionId: pending.questionId, player: pending.player, question: pending.question } : undefined);
|
|
924
|
+
const reply = await this.callJudge('boss-input-classification', prompt, signal, state.stateId);
|
|
925
|
+
const event = validateClassifier(reply, text, pending?.questionId);
|
|
926
|
+
if (!event) return undefined;
|
|
927
|
+
return event;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private pendingQuestion(): { readonly questionId: string; readonly player: string; readonly question: string } | undefined {
|
|
931
|
+
const snapshot = this.actor?.getSnapshot();
|
|
932
|
+
const context = snapshot?.context as unknown;
|
|
933
|
+
if (!isRecord(context) || !isRecord(context.pendingBossQuestion)) return undefined;
|
|
934
|
+
return {
|
|
935
|
+
questionId: assertNonEmptyString(context.pendingBossQuestion.questionId, 'pending question id'),
|
|
936
|
+
player: assertNonEmptyString(context.pendingBossQuestion.player, 'pending question player'),
|
|
937
|
+
question: assertNonEmptyString(context.pendingBossQuestion.question, 'pending question text'),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private enqueueTransition(event: unknown, actor: RootActor): void {
|
|
942
|
+
const state = stateFromSnapshot(actor, this.nestedBridge?.getPendingCall());
|
|
943
|
+
const previousState = this.previousState ?? state;
|
|
944
|
+
this.previousState = state;
|
|
945
|
+
const transition = omitUndefined({
|
|
946
|
+
event: this.describeEvent(event),
|
|
947
|
+
from: previousState,
|
|
948
|
+
to: state,
|
|
949
|
+
previousState,
|
|
950
|
+
state,
|
|
951
|
+
stateId: state.stateId,
|
|
952
|
+
pendingBossQuestion: this.pendingQuestion(),
|
|
953
|
+
lastError: this.lastError(),
|
|
954
|
+
});
|
|
955
|
+
this.enqueue(async () => {
|
|
956
|
+
await this.traceNow('fsm.transition', transition, this.currentTraceTurnId());
|
|
957
|
+
await this.requireSession().ports.emitTelemetry({ topic: 'playbook.fsm.state', payload: transition });
|
|
958
|
+
if (state.stateId !== 'ready' && state.stateId !== 'done') {
|
|
959
|
+
await this.traceNow('status.emitted', omitUndefined({ message: `Entered ${state.stateId ?? 'state'}`, state, stateId: state.stateId }), this.currentTraceTurnId());
|
|
960
|
+
await this.requireSession().ports.emitStatus(`Entered ${state.stateId ?? 'state'}`, transition);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private describeEvent(event: unknown): JsonValue {
|
|
966
|
+
if (!isRecord(event)) return { type: 'unknown' };
|
|
967
|
+
const type = typeof event.type === 'string' ? event.type : 'unknown';
|
|
968
|
+
const copy: Record<string, JsonValue> = { type };
|
|
969
|
+
for (const key of ['bossIntent', 'targetId', 'answer', 'questionId', 'output']) {
|
|
970
|
+
if (key in event && event[key] === undefined) continue;
|
|
971
|
+
if (key in event) copy[key] = snapshotJsonValue(event[key], `event.${key}`);
|
|
972
|
+
}
|
|
973
|
+
if ('error' in event) copy.error = snapshotJsonValue(normalizeError(event.error));
|
|
974
|
+
return snapshotJsonValue(copy);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private lastError(): JsonValue | undefined {
|
|
978
|
+
const context = this.actor?.getSnapshot().context as unknown;
|
|
979
|
+
if (!isRecord(context) || !('lastError' in context)) return undefined;
|
|
980
|
+
if (context.lastError === undefined) return undefined;
|
|
981
|
+
return snapshotJsonValue(context.lastError, 'lastError');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private machineOutput(): JsonValue | undefined {
|
|
985
|
+
const snapshot = this.actor?.getSnapshot();
|
|
986
|
+
if (!snapshot || snapshot.status !== 'done') return undefined;
|
|
987
|
+
const output = snapshot.output as unknown;
|
|
988
|
+
return output === undefined ? undefined : snapshotJsonValue(output, 'machine output');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private async emitStatus(message: string, data?: unknown): Promise<void> {
|
|
992
|
+
const state = stateFromSnapshot(this.requireActor(), this.nestedBridge?.getPendingCall());
|
|
993
|
+
const payload = omitUndefined({ message, data, state, stateId: state.stateId });
|
|
994
|
+
await this.trace('status.emitted', payload, this.currentTraceTurnId());
|
|
995
|
+
await this.requireSession().ports.emitStatus(message, data);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private async traceSettled(result: PlaybookRunResult, turnId: number): Promise<void> {
|
|
999
|
+
await this.trace('boss.input.settled', this.runResultPayload(result), turnId);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private runResultPayload(result: PlaybookRunResult): JsonValue {
|
|
1003
|
+
return omitUndefined({
|
|
1004
|
+
outcome: result.outcome,
|
|
1005
|
+
state: result.state,
|
|
1006
|
+
stateId: result.state.stateId,
|
|
1007
|
+
pendingCall: 'pendingCall' in result ? result.pendingCall : undefined,
|
|
1008
|
+
output: 'output' in result ? result.output : undefined,
|
|
1009
|
+
error: 'error' in result ? result.error : undefined,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private async trace(type: PlaybookTraceEvent['type'], payload: unknown, turnId?: number, callId?: string): Promise<void> {
|
|
1014
|
+
this.enqueue(async () => {
|
|
1015
|
+
await this.traceNow(type, payload, turnId, callId);
|
|
1016
|
+
});
|
|
1017
|
+
await this.drain();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private async tracePreservingError(
|
|
1021
|
+
type: PlaybookTraceEvent['type'],
|
|
1022
|
+
payload: unknown,
|
|
1023
|
+
preservedError: unknown,
|
|
1024
|
+
turnId?: number,
|
|
1025
|
+
callId?: string,
|
|
1026
|
+
): Promise<void> {
|
|
1027
|
+
const previous = this.latchedControlError;
|
|
1028
|
+
this.latchedControlError = undefined;
|
|
1029
|
+
try {
|
|
1030
|
+
await this.trace(type, payload, turnId, callId);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
// Preserve the earlier boundary/control failure.
|
|
1033
|
+
} finally {
|
|
1034
|
+
this.latchedControlError = previous ?? preservedError;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private async traceNow(type: PlaybookTraceEvent['type'], payload: unknown, turnId?: number, callId?: string): Promise<void> {
|
|
1039
|
+
const session = this.requireSession();
|
|
1040
|
+
const event: PlaybookTraceEvent = {
|
|
1041
|
+
schemaVersion: 2,
|
|
1042
|
+
sessionId: session.sessionId,
|
|
1043
|
+
playbookId: session.playbookId,
|
|
1044
|
+
rootSessionId: session.rootSessionId,
|
|
1045
|
+
...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }),
|
|
1046
|
+
...(session.parentCallId === undefined ? {} : { parentCallId: session.parentCallId }),
|
|
1047
|
+
depth: session.depth,
|
|
1048
|
+
sequence: this.nextSequence(),
|
|
1049
|
+
timestamp: Date.now(),
|
|
1050
|
+
type,
|
|
1051
|
+
...(turnId === undefined ? {} : { turnId }),
|
|
1052
|
+
...(callId === undefined ? {} : { callId }),
|
|
1053
|
+
payload: snapshotJsonValue(payload, `trace ${type}`),
|
|
1054
|
+
};
|
|
1055
|
+
await session.ports.emitTelemetry({ topic: 'playbook.trace', payload: event });
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private enqueue(task: () => Promise<void>): void {
|
|
1059
|
+
void this.emissionQueue.add(async () => {
|
|
1060
|
+
try {
|
|
1061
|
+
await task();
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
this.latchControlError(error);
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
}).catch(() => undefined);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private drain(): Promise<void> {
|
|
1070
|
+
return this.emissionQueue.onIdle().then(() => {
|
|
1071
|
+
if (this.latchedControlError) throw this.latchedControlError;
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private async bestEffortDisposeTrace(state: PlaybookState): Promise<void> {
|
|
1076
|
+
try {
|
|
1077
|
+
this.disposalTraceEmitted = true;
|
|
1078
|
+
await this.trace('session.disposed', omitUndefined({ state, stateId: state.stateId }));
|
|
1079
|
+
await this.drain();
|
|
1080
|
+
} catch {
|
|
1081
|
+
// Preserve the original initialization error.
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private reconstructActor(): void {
|
|
1086
|
+
this.actor?.stop();
|
|
1087
|
+
const session = this.requireSession();
|
|
1088
|
+
const bridge = this.requireBridge();
|
|
1089
|
+
this.actor = this.createActor(session, bridge);
|
|
1090
|
+
this.actor.start();
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private requireSession(): RuntimeSession {
|
|
1094
|
+
if (!this.session) throw new Error('playbook runtime is not initialized');
|
|
1095
|
+
return this.session;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
private requireActor(): RootActor {
|
|
1099
|
+
if (!this.actor) throw new Error('playbook runtime actor is not initialized');
|
|
1100
|
+
return this.actor;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private requireBridge(): ReturnType<typeof createNestedPlaybookBridge<PlaybookInput>> {
|
|
1104
|
+
if (!this.nestedBridge) throw new Error('nested bridge is not initialized');
|
|
1105
|
+
return this.nestedBridge;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private nextSequence(): number {
|
|
1109
|
+
this.sequence += 1;
|
|
1110
|
+
return this.sequence;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private nextTurnId(): number {
|
|
1114
|
+
this.turnId += 1;
|
|
1115
|
+
return this.turnId;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private currentTraceTurnId(): number | undefined {
|
|
1119
|
+
return this.boundaryTurnId;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private nextCallId(): number {
|
|
1123
|
+
this.callId += 1;
|
|
1124
|
+
return this.callId;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private latchControlError(error: unknown): void {
|
|
1128
|
+
if (!this.latchedControlError) this.latchedControlError = error;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export const _internal = {
|
|
1133
|
+
composeCaptainPrompt,
|
|
1134
|
+
composePlayerPrompt,
|
|
1135
|
+
parseJsonObjectLoose,
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
export function createPlaybookRuntime(options: PlaybookRuntimeOptions): PlaybookRuntime {
|
|
1139
|
+
return new CaptainPlaybookRuntime(options);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> = createPlaybookRuntime;
|
|
1143
|
+
|
|
1144
|
+
export default factory;
|