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