@sublang/playbook 4.0.0 → 6.0.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 +69 -122
- package/docs/assets/playbook-venn.svg +13 -0
- package/docs/cli.md +43 -26
- package/docs/configuration.md +63 -18
- package/docs/embedding.md +24 -16
- package/package.json +43 -22
- package/reference/sdlc/captain.md +70 -83
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
- package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
- package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
- package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
- package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
- package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
- package/reference/sdlc/code.md +55 -97
- package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -639
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
- package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +511 -370
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +67 -6
- package/slc/link.md +339 -25
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +36 -1
- package/src/runtime.ts +59 -0
- package/src/xstate-playbook-runtime.d.ts +96 -7
- package/src/xstate-playbook-runtime.js +1018 -49
- package/src/xstate-playbook-runtime.ts +1283 -59
- package/src/xstate-runtime.js +25 -0
- package/src/xstate-runtime.ts +51 -0
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
//
|
|
4
|
-
// slc
|
|
5
|
-
// FSM
|
|
6
|
-
// Player
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// Generated by slc/link.md (FSM-to-Runtime linker).
|
|
5
|
+
// Source FSM: ./captain.fsm.ts
|
|
6
|
+
// Player bind: none (the session Captain declares no player behavior)
|
|
7
|
+
// Boss event: deterministic controller entry (slc/link.md §Boss-event
|
|
8
|
+
// mapping): every turn maps from the exact Boss text and the
|
|
9
|
+
// host's command-parse resolution supplied through the linked
|
|
10
|
+
// options' controller port — BOSS_TURN / PARSED_RESPOND /
|
|
11
|
+
// PARSED_ACTION / SHUTDOWN — with no classifier judge call.
|
|
12
|
+
// Captain calls: hidden controller form (slc/link.md §Captain adjudication,
|
|
13
|
+
// DR-029): decision and closing-reply calls run
|
|
14
|
+
// `{ visibility: 'hidden' }`; the host's session-Captain
|
|
15
|
+
// wrapper owns the durable-conversation resume selection and
|
|
16
|
+
// pins the returned token (the runtime requests
|
|
17
|
+
// `resume: false` as the typed placeholder and its traces
|
|
18
|
+
// carry no resume member). The decision reply is `{ action,
|
|
19
|
+
// … }` control JSON validated here against the declared
|
|
20
|
+
// decision-state contract — never adjudicated through a
|
|
21
|
+
// judge call and never Boss presentation; controller prose
|
|
22
|
+
// reaches Boss only as host-validated captain speech through
|
|
23
|
+
// the host's presentation seam (cligent
|
|
24
|
+
// `CaptainContext.emitReply`).
|
|
25
|
+
// Contract: types imported and re-exported from
|
|
26
|
+
// @sublang/playbook/runtime (slc/link.md §Output); the
|
|
27
|
+
// shared createXStatePlaybookRuntime factory from
|
|
28
|
+
// @sublang/playbook/xstate-runtime interprets the FSM
|
|
29
|
+
// (DR-019). This module carries only the Captain-specific
|
|
30
|
+
// spec: options validation (catalog + controller port),
|
|
31
|
+
// deterministic entry mapping, the controller captain-call
|
|
32
|
+
// strategy with its single corrective re-ask (CAPPLAY-18),
|
|
33
|
+
// controller-port submission, and status formatting.
|
|
34
|
+
// Compat: spec.compat = { artifactSchema: 1, runtimeAbi: 1 }
|
|
35
|
+
// (DR-022; checked at construction by the loading engine).
|
|
36
|
+
import { createXStatePlaybookRuntime, defaultComposeCaptainPrompt, normalizeError, normalizeErrorCompact, parseJudgeJson, snapshotJsonValue, RUNTIME_ABI, } from '../../../src/xstate-runtime.js';
|
|
11
37
|
import { captainMachine, } from './captain.fsm.js';
|
|
12
|
-
import { assertJsonSafe, combineAbortSignals, createNestedPlaybookBridge, defaultBuildCaptainJudgePrompt, 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
38
|
function assertNonEmptyString(value, label) {
|
|
20
39
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
21
40
|
throw new TypeError(`${label} must be a non-empty string`);
|
|
@@ -29,66 +48,6 @@ function isRecord(value) {
|
|
|
29
48
|
const prototype = Object.getPrototypeOf(value);
|
|
30
49
|
return prototype === Object.prototype || prototype === null;
|
|
31
50
|
}
|
|
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
51
|
function validateEnabledPlaybooks(value) {
|
|
93
52
|
if (!Array.isArray(value)) {
|
|
94
53
|
throw new TypeError('enabledPlaybooks must be an array');
|
|
@@ -112,950 +71,481 @@ function validateEnabledPlaybooks(value) {
|
|
|
112
71
|
return Object.freeze({ id, command, intent });
|
|
113
72
|
}));
|
|
114
73
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
}
|
|
74
|
+
function validateControllerPort(value) {
|
|
75
|
+
if (value === null || typeof value !== 'object') {
|
|
76
|
+
throw new TypeError('options.controller must be an object');
|
|
132
77
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
}
|
|
78
|
+
const record = value;
|
|
79
|
+
if (typeof record.submit !== 'function') {
|
|
80
|
+
throw new TypeError('options.controller.submit must be a function');
|
|
159
81
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
function
|
|
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();
|
|
82
|
+
if (record.resolveParsedTurn !== undefined &&
|
|
83
|
+
typeof record.resolveParsedTurn !== 'function') {
|
|
84
|
+
throw new TypeError('options.controller.resolveParsedTurn must be a function when present');
|
|
185
85
|
}
|
|
186
|
-
|
|
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;
|
|
86
|
+
return value;
|
|
208
87
|
}
|
|
209
|
-
function
|
|
210
|
-
|
|
88
|
+
function snapshotCaptainOptions(value) {
|
|
89
|
+
if (!isRecord(value)) {
|
|
90
|
+
throw new TypeError('Captain runtime options must be an object');
|
|
91
|
+
}
|
|
92
|
+
const allowed = new Set(['enabledPlaybooks', 'controller']);
|
|
93
|
+
for (const key of Object.keys(value)) {
|
|
94
|
+
if (!allowed.has(key)) {
|
|
95
|
+
throw new TypeError(`Captain runtime options.${key} is not declared`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const enabledPlaybooks = validateEnabledPlaybooks(value.enabledPlaybooks);
|
|
99
|
+
const controller = value.controller === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: validateControllerPort(value.controller);
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
enabledPlaybooks,
|
|
104
|
+
...(controller === undefined ? {} : { controller }),
|
|
105
|
+
});
|
|
211
106
|
}
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'
|
|
220
|
-
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Deterministic controller entry mapping (slc/link.md §Boss-event mapping):
|
|
109
|
+
// the exact Boss text rides the runtime-owned `bossText` field; the host's
|
|
110
|
+
// parse resolution selects the hub entry arm; no classifier judge call runs.
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
function validateParsedActingDecision(value) {
|
|
113
|
+
if (!isRecord(value)) {
|
|
114
|
+
throw new TypeError('parse-resolved decision must be an object');
|
|
115
|
+
}
|
|
116
|
+
if (value.action === 'deliver') {
|
|
117
|
+
if (Object.keys(value).length !== 1) {
|
|
118
|
+
throw new TypeError('a parse-resolved deliver decision carries no payload');
|
|
119
|
+
}
|
|
120
|
+
return { action: 'deliver' };
|
|
121
|
+
}
|
|
122
|
+
if (value.action !== 'start' && value.action !== 'switch') {
|
|
123
|
+
throw new TypeError(`parse-resolved decision names unknown action ${String(value.action)}`);
|
|
124
|
+
}
|
|
125
|
+
const allowed = new Set(['action', 'playbookId', 'input']);
|
|
126
|
+
for (const key of Object.keys(value)) {
|
|
127
|
+
if (!allowed.has(key)) {
|
|
128
|
+
throw new TypeError(`parse-resolved decision carries undeclared ${key}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
action: value.action,
|
|
133
|
+
playbookId: assertNonEmptyString(value.playbookId, 'parse-resolved decision playbookId'),
|
|
134
|
+
input: assertNonEmptyString(value.input, 'parse-resolved decision input'),
|
|
135
|
+
};
|
|
221
136
|
}
|
|
222
|
-
function
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
throw new
|
|
229
|
-
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
if (guard === 'delegation' || guard === 'continuing') {
|
|
246
|
-
const missing = ['remainingPlan', 'nextPlaybookId', 'nextPlaybookInput'].filter((field) => !(field in parsed));
|
|
247
|
-
if (missing.length > 0) {
|
|
248
|
-
throw new Error(`adjudicator omitted required field ${missing.join(', ')}`);
|
|
249
|
-
}
|
|
250
|
-
const remainingPlan = snapshotJsonValue(parsed.remainingPlan, 'remainingPlan');
|
|
251
|
-
if (!Array.isArray(remainingPlan))
|
|
252
|
-
throw new Error('adjudicator remainingPlan must be a JSON array');
|
|
253
|
-
return {
|
|
254
|
-
guard,
|
|
255
|
-
remainingPlan,
|
|
256
|
-
nextPlaybookId: assertNonEmptyString(parsed.nextPlaybookId, 'nextPlaybookId'),
|
|
257
|
-
nextPlaybookInput: assertNonEmptyString(parsed.nextPlaybookInput, 'nextPlaybookInput'),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
throw new Error(`adjudicator selected unsupported guard ${guard}`);
|
|
261
|
-
}
|
|
262
|
-
function validateClassifier(text, bossText, pendingQuestionId) {
|
|
263
|
-
const parsed = parseJsonObjectLoose(text);
|
|
264
|
-
if (!parsed)
|
|
265
|
-
return undefined;
|
|
266
|
-
const type = parsed.type;
|
|
267
|
-
if (type === 'NO_ACTION') {
|
|
268
|
-
if (Object.keys(parsed).length !== 1)
|
|
269
|
-
return undefined;
|
|
270
|
-
return { type: 'NO_ACTION' };
|
|
271
|
-
}
|
|
272
|
-
if (type === 'BOSS_INTENT') {
|
|
273
|
-
if (Object.keys(parsed).length !== 1)
|
|
274
|
-
return undefined;
|
|
275
|
-
return { type: 'BOSS_INTENT', bossIntent: bossText };
|
|
276
|
-
}
|
|
277
|
-
if (type === 'BOSS_INTERRUPT') {
|
|
278
|
-
if (Object.keys(parsed).sort().join('\0') !== ['targetId', 'type'].join('\0'))
|
|
279
|
-
return undefined;
|
|
280
|
-
if (parsed.targetId !== 'routing')
|
|
281
|
-
return undefined;
|
|
282
|
-
return { type: 'BOSS_INTERRUPT', targetId: 'routing', bossIntent: bossText };
|
|
283
|
-
}
|
|
284
|
-
if (type === 'BOSS_REPLY') {
|
|
285
|
-
const keys = Object.keys(parsed).sort();
|
|
286
|
-
if (keys.join('\0') !== ['questionId', 'type'].join('\0') && keys.join('\0') !== 'type')
|
|
287
|
-
return undefined;
|
|
288
|
-
const questionId = parsed.questionId === undefined ? pendingQuestionId : parsed.questionId;
|
|
289
|
-
if (questionId !== pendingQuestionId || typeof questionId !== 'string')
|
|
290
|
-
return undefined;
|
|
291
|
-
return { type: 'BOSS_REPLY', answer: bossText, questionId };
|
|
137
|
+
function classifyControllerTurn(text, _ports, _signal, _snapshotOrState, _boundary, options) {
|
|
138
|
+
const resolution = options?.controller?.resolveParsedTurn?.(text);
|
|
139
|
+
if (resolution === undefined) {
|
|
140
|
+
return Promise.resolve({ type: 'BOSS_TURN', bossText: text });
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(resolution)) {
|
|
143
|
+
throw new TypeError('controller parse resolution must be an object');
|
|
144
|
+
}
|
|
145
|
+
const widened = resolution;
|
|
146
|
+
switch (widened.kind) {
|
|
147
|
+
case 'respond':
|
|
148
|
+
return Promise.resolve({ type: 'PARSED_RESPOND', bossText: text });
|
|
149
|
+
case 'action':
|
|
150
|
+
return Promise.resolve({
|
|
151
|
+
type: 'PARSED_ACTION',
|
|
152
|
+
bossText: text,
|
|
153
|
+
decision: validateParsedActingDecision(widened.decision),
|
|
154
|
+
});
|
|
155
|
+
case 'shutdown':
|
|
156
|
+
return Promise.resolve({ type: 'SHUTDOWN' });
|
|
157
|
+
default:
|
|
158
|
+
throw new TypeError(`controller parse resolution names unknown kind ${String(widened.kind)}`);
|
|
292
159
|
}
|
|
293
|
-
return undefined;
|
|
294
160
|
}
|
|
295
|
-
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Decision-reply validation (CAPPLAY-18): the hidden decision call's reply is
|
|
163
|
+
// `{ action, … }` control JSON over the closed set, validated against the
|
|
164
|
+
// declared decision-state contract — known action, required payload fields,
|
|
165
|
+
// catalog membership, never this Captain playbook itself as a target. A
|
|
166
|
+
// malformed reply earns exactly one corrective call appending the rejection
|
|
167
|
+
// reason and the restated reply contract; a second malformed reply settles
|
|
168
|
+
// the turn as a recoverable hub return with no action executed.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
const RESTATED_REPLY_CONTRACT = [
|
|
171
|
+
'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
|
|
172
|
+
'`{ "action": "respond", "text": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
|
|
173
|
+
].join('\n');
|
|
174
|
+
function correctiveDecisionPrompt(prompt, reason) {
|
|
296
175
|
return [
|
|
297
|
-
|
|
176
|
+
prompt,
|
|
298
177
|
'',
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
'',
|
|
302
|
-
'Current state:',
|
|
303
|
-
stableJson(state),
|
|
304
|
-
'',
|
|
305
|
-
'Pending Boss question:',
|
|
306
|
-
stableJson(pending ?? null),
|
|
307
|
-
'',
|
|
308
|
-
'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"}.',
|
|
178
|
+
`Your previous control reply was rejected: ${reason}.`,
|
|
179
|
+
RESTATED_REPLY_CONTRACT,
|
|
309
180
|
].join('\n');
|
|
310
181
|
}
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (pendingCall)
|
|
316
|
-
return { outcome: 'suspended', state, pendingCall };
|
|
317
|
-
if (state.status === 'done') {
|
|
318
|
-
return output === undefined ? { outcome: 'terminal', state } : { outcome: 'terminal', state, output };
|
|
319
|
-
}
|
|
320
|
-
if (state.stateId === 'failed') {
|
|
321
|
-
return error === undefined ? { outcome: 'failed', state } : { outcome: 'failed', state, error: normalizeError(error) };
|
|
322
|
-
}
|
|
323
|
-
return { outcome: 'quiescent', state };
|
|
324
|
-
}
|
|
325
|
-
function isAbortLikeError(error) {
|
|
326
|
-
return normalizeError(error).name === 'AbortError';
|
|
327
|
-
}
|
|
328
|
-
function isSignalAbort(error, signal) {
|
|
329
|
-
return signal.aborted && error === signal.reason;
|
|
330
|
-
}
|
|
331
|
-
class CaptainPlaybookRuntime {
|
|
332
|
-
enabledPlaybooks;
|
|
333
|
-
emissionQueue = new PQueue({ concurrency: 1 });
|
|
334
|
-
captainLane = new PQueue({ concurrency: 1 });
|
|
335
|
-
session;
|
|
336
|
-
actor;
|
|
337
|
-
nestedBridge;
|
|
338
|
-
sequence = 0;
|
|
339
|
-
turnId = 0;
|
|
340
|
-
callId = 0;
|
|
341
|
-
boundaryTurnId;
|
|
342
|
-
playbookCallTurnIds = new Map();
|
|
343
|
-
activeBoundarySignal;
|
|
344
|
-
activeTurn;
|
|
345
|
-
disposing;
|
|
346
|
-
disposed = false;
|
|
347
|
-
terminallyDisposedBeforeInit = false;
|
|
348
|
-
disposalTraceEmitted = false;
|
|
349
|
-
initializing = false;
|
|
350
|
-
initializationDone;
|
|
351
|
-
resolveInitializationDone;
|
|
352
|
-
latchedControlError;
|
|
353
|
-
suppressInspection = false;
|
|
354
|
-
previousState;
|
|
355
|
-
constructor(options) {
|
|
356
|
-
this.enabledPlaybooks = validateEnabledPlaybooks(options.enabledPlaybooks);
|
|
357
|
-
}
|
|
358
|
-
async init(session) {
|
|
359
|
-
if (this.session || this.actor)
|
|
360
|
-
throw new Error('playbook runtime is already initialized');
|
|
361
|
-
if (this.disposed || this.terminallyDisposedBeforeInit || this.disposing)
|
|
362
|
-
throw new Error('playbook runtime is disposed');
|
|
363
|
-
this.initializing = true;
|
|
364
|
-
this.initializationDone = new Promise((resolve) => {
|
|
365
|
-
this.resolveInitializationDone = resolve;
|
|
366
|
-
});
|
|
367
|
-
this.disposalTraceEmitted = false;
|
|
368
|
-
let actor;
|
|
369
|
-
let initialState;
|
|
370
|
-
try {
|
|
371
|
-
const captured = snapshotPlaybookSession(session);
|
|
372
|
-
this.session = captured;
|
|
373
|
-
this.nestedBridge = this.createBridge(captured);
|
|
374
|
-
actor = this.createActor(captured, this.nestedBridge);
|
|
375
|
-
this.actor = actor;
|
|
376
|
-
initialState = stateFromSnapshot(actor);
|
|
377
|
-
this.previousState = initialState;
|
|
378
|
-
await this.trace('session.started', omitUndefined({ state: initialState, stateId: initialState.stateId }));
|
|
379
|
-
await this.drain();
|
|
380
|
-
actor.start();
|
|
381
|
-
await this.drain();
|
|
382
|
-
}
|
|
383
|
-
catch (error) {
|
|
384
|
-
this.suppressInspection = true;
|
|
385
|
-
actor?.stop();
|
|
386
|
-
if (initialState && !this.disposalTraceEmitted)
|
|
387
|
-
await this.bestEffortDisposeTrace(initialState);
|
|
388
|
-
this.session = undefined;
|
|
389
|
-
this.actor = undefined;
|
|
390
|
-
this.nestedBridge = undefined;
|
|
391
|
-
this.sequence = 0;
|
|
392
|
-
this.turnId = 0;
|
|
393
|
-
this.callId = 0;
|
|
394
|
-
this.latchedControlError = undefined;
|
|
395
|
-
this.previousState = undefined;
|
|
396
|
-
this.suppressInspection = false;
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
399
|
-
finally {
|
|
400
|
-
this.initializing = false;
|
|
401
|
-
this.resolveInitializationDone?.();
|
|
402
|
-
this.resolveInitializationDone = undefined;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
async handleBossInput(turn) {
|
|
406
|
-
if (this.activeTurn)
|
|
407
|
-
throw new Error('playbook runtime already has an active boundary');
|
|
408
|
-
if (this.disposing || this.disposed)
|
|
409
|
-
throw new Error('playbook runtime is disposing');
|
|
410
|
-
const run = this.handleBossInputInner(turn);
|
|
411
|
-
this.activeTurn = run;
|
|
412
|
-
try {
|
|
413
|
-
return await run;
|
|
414
|
-
}
|
|
415
|
-
catch (error) {
|
|
416
|
-
if (isSignalAbort(error, turn.signal)) {
|
|
417
|
-
const actor = this.actor;
|
|
418
|
-
const bridge = this.nestedBridge;
|
|
419
|
-
const snapshot = actor
|
|
420
|
-
? await waitForPlaybookQuiescence(actor, { pendingCalls: bridge })
|
|
421
|
-
: undefined;
|
|
422
|
-
const state = snapshot
|
|
423
|
-
? normalizePlaybookSnapshot(snapshot, { pendingCall: bridge?.getPendingCall() })
|
|
424
|
-
: { value: 'failed', activeStateIds: ['failed'], tags: ['playbook.parked'], status: 'active', quiescent: true, stateId: 'failed' };
|
|
425
|
-
try {
|
|
426
|
-
await this.drain();
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
// The signal-driven abort remains the public outcome.
|
|
430
|
-
}
|
|
431
|
-
return { outcome: 'aborted', state, error: normalizeError(error) };
|
|
432
|
-
}
|
|
433
|
-
throw error;
|
|
434
|
-
}
|
|
435
|
-
finally {
|
|
436
|
-
this.activeTurn = undefined;
|
|
437
|
-
const error = this.latchedControlError;
|
|
438
|
-
this.latchedControlError = undefined;
|
|
439
|
-
const aborted = this.activeBoundarySignal?.aborted === true;
|
|
440
|
-
this.activeBoundarySignal = undefined;
|
|
441
|
-
this.boundaryTurnId = undefined;
|
|
442
|
-
if (error && (!aborted || !isAbortLikeError(error)))
|
|
443
|
-
throw error;
|
|
444
|
-
}
|
|
182
|
+
function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = parseJudgeJson(reply);
|
|
445
186
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
throw new Error('playbook runtime already has an active boundary');
|
|
449
|
-
if (this.disposing || this.disposed)
|
|
450
|
-
throw new Error('playbook runtime is disposing');
|
|
451
|
-
const run = this.resumePlaybookCallInner(input);
|
|
452
|
-
this.activeTurn = run;
|
|
453
|
-
try {
|
|
454
|
-
return await run;
|
|
455
|
-
}
|
|
456
|
-
finally {
|
|
457
|
-
this.activeTurn = undefined;
|
|
458
|
-
const error = this.latchedControlError;
|
|
459
|
-
this.latchedControlError = undefined;
|
|
460
|
-
const aborted = this.activeBoundarySignal?.aborted === true;
|
|
461
|
-
this.activeBoundarySignal = undefined;
|
|
462
|
-
this.boundaryTurnId = undefined;
|
|
463
|
-
if (error && (!aborted || !isAbortLikeError(error)))
|
|
464
|
-
throw error;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
dispose() {
|
|
468
|
-
if (this.activeTurn)
|
|
469
|
-
return Promise.reject(new Error('cannot dispose during an active boundary'));
|
|
470
|
-
if (this.disposing)
|
|
471
|
-
return this.disposing;
|
|
472
|
-
if (!this.initializing && !this.session && !this.actor && !this.disposed) {
|
|
473
|
-
this.terminallyDisposedBeforeInit = true;
|
|
474
|
-
this.disposed = true;
|
|
475
|
-
this.disposing = Promise.resolve();
|
|
476
|
-
return this.disposing;
|
|
477
|
-
}
|
|
478
|
-
this.disposing = this.disposeInner();
|
|
479
|
-
return this.disposing;
|
|
187
|
+
catch {
|
|
188
|
+
return { reason: 'the reply contains no recoverable JSON object' };
|
|
480
189
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const nestedBridge = this.requireBridge();
|
|
484
|
-
const currentTurnId = this.nextTurnId();
|
|
485
|
-
this.boundaryTurnId = currentTurnId;
|
|
486
|
-
this.activeBoundarySignal = turn.signal;
|
|
487
|
-
await this.trace('boss.input.received', { text: turn.text }, currentTurnId);
|
|
488
|
-
const state = stateFromSnapshot(actor, nestedBridge.getPendingCall());
|
|
489
|
-
let event;
|
|
490
|
-
if (turn.text.trim().length === 0) {
|
|
491
|
-
const result = { outcome: 'no-action', state };
|
|
492
|
-
await this.traceSettled(result, currentTurnId);
|
|
493
|
-
await this.drain();
|
|
494
|
-
return result;
|
|
495
|
-
}
|
|
496
|
-
if (state.stateId === 'ready' || state.stateId === 'failed') {
|
|
497
|
-
event = { type: 'BOSS_INTENT', bossIntent: turn.text };
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
try {
|
|
501
|
-
event = await this.classifyBossInput(turn.text, state, turn.signal);
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
if (isSignalAbort(error, turn.signal)) {
|
|
505
|
-
const result = { outcome: 'aborted', state, error: normalizeError(error) };
|
|
506
|
-
await this.traceSettled(result, currentTurnId);
|
|
507
|
-
await this.drain();
|
|
508
|
-
return result;
|
|
509
|
-
}
|
|
510
|
-
await this.trace('boss.input.settled', omitUndefined({
|
|
511
|
-
outcome: 'no-action',
|
|
512
|
-
state,
|
|
513
|
-
stateId: state.stateId,
|
|
514
|
-
error: normalizeError(error),
|
|
515
|
-
}), currentTurnId);
|
|
516
|
-
await this.drain();
|
|
517
|
-
throw error;
|
|
518
|
-
}
|
|
519
|
-
if (!event) {
|
|
520
|
-
await this.emitStatus('classification was invalid; Boss input was not actionable.', { state });
|
|
521
|
-
const result = { outcome: 'no-action', state };
|
|
522
|
-
await this.traceSettled(result, currentTurnId);
|
|
523
|
-
await this.drain();
|
|
524
|
-
return result;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (event?.type === 'NO_ACTION') {
|
|
528
|
-
const result = { outcome: 'no-action', state };
|
|
529
|
-
await this.traceSettled(result, currentTurnId);
|
|
530
|
-
await this.drain();
|
|
531
|
-
return result;
|
|
532
|
-
}
|
|
533
|
-
if (turn.signal.aborted) {
|
|
534
|
-
const result = { outcome: 'aborted', state, error: normalizeError(turn.signal.reason) };
|
|
535
|
-
await this.traceSettled(result, currentTurnId);
|
|
536
|
-
await this.drain();
|
|
537
|
-
return result;
|
|
538
|
-
}
|
|
539
|
-
if (actor.getSnapshot().status === 'done') {
|
|
540
|
-
this.reconstructActor();
|
|
541
|
-
}
|
|
542
|
-
this.requireActor().send(event);
|
|
543
|
-
const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
|
|
544
|
-
const settledState = normalizePlaybookSnapshot(snapshot, { pendingCall: nestedBridge.getPendingCall() });
|
|
545
|
-
const result = turn.signal.aborted
|
|
546
|
-
? { outcome: 'aborted', state: settledState, error: normalizeError(turn.signal.reason) }
|
|
547
|
-
: resultFromState(settledState, this.machineOutput(), nestedBridge.getPendingCall(), this.latchedControlError);
|
|
548
|
-
await this.traceSettled(result, currentTurnId);
|
|
549
|
-
await this.drain();
|
|
550
|
-
return result;
|
|
190
|
+
if (!isRecord(parsed)) {
|
|
191
|
+
return { reason: 'the reply is not a JSON object' };
|
|
551
192
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
await nestedBridge.resume(input);
|
|
559
|
-
}
|
|
560
|
-
catch (error) {
|
|
561
|
-
resumeError = error;
|
|
562
|
-
}
|
|
563
|
-
const snapshot = await waitForPlaybookQuiescence(this.requireActor(), { pendingCalls: nestedBridge });
|
|
564
|
-
const pendingCall = nestedBridge.getPendingCall();
|
|
565
|
-
const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
|
|
566
|
-
const result = resultFromState(state, this.machineOutput(), pendingCall);
|
|
567
|
-
await this.drain();
|
|
568
|
-
if (resumeError !== undefined)
|
|
569
|
-
throw resumeError;
|
|
570
|
-
return input.signal.aborted ? { outcome: 'aborted', state, error: normalizeError(input.signal.reason) } : result;
|
|
193
|
+
const action = parsed.action;
|
|
194
|
+
if (typeof action !== 'string' || !declaredActions.has(action)) {
|
|
195
|
+
return {
|
|
196
|
+
reason: `the reply names no known action (got ${JSON.stringify(action ?? null)})`,
|
|
197
|
+
};
|
|
571
198
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
await this.initializationDone;
|
|
577
|
-
}
|
|
578
|
-
const actor = this.actor;
|
|
579
|
-
const bridge = this.nestedBridge;
|
|
580
|
-
const finalState = actor ? stateFromSnapshot(actor, bridge?.getPendingCall()) : undefined;
|
|
581
|
-
let cleanupError;
|
|
582
|
-
this.suppressInspection = true;
|
|
583
|
-
actor?.stop();
|
|
584
|
-
try {
|
|
585
|
-
await bridge?.dispose();
|
|
586
|
-
}
|
|
587
|
-
catch (error) {
|
|
588
|
-
cleanupError = error;
|
|
589
|
-
}
|
|
590
|
-
if (this.initializing) {
|
|
591
|
-
try {
|
|
592
|
-
await this.drain();
|
|
593
|
-
}
|
|
594
|
-
catch (error) {
|
|
595
|
-
if (cleanupError === undefined)
|
|
596
|
-
cleanupError = error;
|
|
199
|
+
const requireKeys = (required, tolerated = []) => {
|
|
200
|
+
for (const key of required) {
|
|
201
|
+
if (!(key in parsed)) {
|
|
202
|
+
return `the ${action} selection omits required field \`${key}\``;
|
|
597
203
|
}
|
|
598
204
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
205
|
+
for (const key of Object.keys(parsed)) {
|
|
206
|
+
if (key !== 'action' &&
|
|
207
|
+
!required.includes(key) &&
|
|
208
|
+
!tolerated.includes(key)) {
|
|
209
|
+
return `the ${action} selection carries undeclared field \`${key}\``;
|
|
602
210
|
}
|
|
603
|
-
catch (error) {
|
|
604
|
-
if (cleanupError === undefined)
|
|
605
|
-
cleanupError = error;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
this.latchedControlError = undefined;
|
|
609
|
-
if (finalState && !this.disposalTraceEmitted) {
|
|
610
|
-
this.disposalTraceEmitted = true;
|
|
611
|
-
try {
|
|
612
|
-
await this.trace('session.disposed', omitUndefined({ state: finalState, stateId: finalState.stateId }));
|
|
613
|
-
}
|
|
614
|
-
catch (error) {
|
|
615
|
-
if (cleanupError === undefined)
|
|
616
|
-
cleanupError = error;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
await this.drain();
|
|
621
211
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
input: {
|
|
647
|
-
enabledPlaybooks: this.enabledPlaybooks,
|
|
648
|
-
selfPlaybookId: session.playbookId,
|
|
649
|
-
},
|
|
650
|
-
inspect: (inspectionEvent) => {
|
|
651
|
-
if (this.suppressInspection)
|
|
652
|
-
return;
|
|
653
|
-
if (inspectionEvent.type !== '@xstate.snapshot')
|
|
654
|
-
return;
|
|
655
|
-
if (inspectionEvent.actorRef !== rootActor)
|
|
656
|
-
return;
|
|
657
|
-
try {
|
|
658
|
-
this.enqueueTransition(inspectionEvent.event, rootActor);
|
|
659
|
-
}
|
|
660
|
-
catch (error) {
|
|
661
|
-
this.latchControlError(error);
|
|
662
|
-
}
|
|
663
|
-
},
|
|
664
|
-
});
|
|
665
|
-
return rootActor;
|
|
666
|
-
}
|
|
667
|
-
createBridge(session) {
|
|
668
|
-
return createNestedPlaybookBridge({
|
|
669
|
-
nextCallId: () => `call-${this.nextCallId()}`,
|
|
670
|
-
getBoundarySignal: () => this.activeBoundarySignal,
|
|
671
|
-
callPlaybook: (request, signal) => session.ports.callPlaybook(request, signal),
|
|
672
|
-
emitStarted: async (event) => {
|
|
673
|
-
const turnId = this.currentTraceTurnId();
|
|
674
|
-
if (turnId !== undefined)
|
|
675
|
-
this.playbookCallTurnIds.set(event.callId, turnId);
|
|
676
|
-
await this.trace('playbook.call.started', {
|
|
677
|
-
stateId: event.stateId,
|
|
678
|
-
playbookId: event.playbookId,
|
|
679
|
-
text: event.text,
|
|
680
|
-
}, turnId, event.callId);
|
|
681
|
-
},
|
|
682
|
-
emitFinished: async (event) => {
|
|
683
|
-
const turnId = this.playbookCallTurnIds.get(event.callId) ?? this.currentTraceTurnId();
|
|
684
|
-
await this.trace('playbook.call.finished', {
|
|
685
|
-
stateId: event.stateId,
|
|
686
|
-
playbookId: event.playbookId,
|
|
687
|
-
text: event.text,
|
|
688
|
-
result: event.result,
|
|
689
|
-
}, turnId, event.callId);
|
|
690
|
-
this.playbookCallTurnIds.delete(event.callId);
|
|
691
|
-
},
|
|
692
|
-
drain: () => this.drain(),
|
|
693
|
-
bindResumeSignal: (signal) => {
|
|
694
|
-
this.activeBoundarySignal = signal;
|
|
695
|
-
},
|
|
696
|
-
onControlPlaneError: (error) => this.latchControlError(error),
|
|
697
|
-
onBackgroundError: (error) => this.latchControlError(error),
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
async runCaptainActor(input, signal) {
|
|
701
|
-
try {
|
|
702
|
-
const prompt = composeCaptainPrompt(input);
|
|
703
|
-
const result = await this.callCaptain(input, prompt, signal);
|
|
704
|
-
if (signal.aborted)
|
|
705
|
-
throw signal.reason;
|
|
706
|
-
if (result.status !== 'ok') {
|
|
707
|
-
throw new Error(result.error ?? `Captain returned ${result.status}`);
|
|
708
|
-
}
|
|
709
|
-
if (!result.finalText) {
|
|
710
|
-
throw new Error('Captain returned ok without finalText');
|
|
711
|
-
}
|
|
712
|
-
const judgePrompt = makeJudgePrompt(input, result.finalText);
|
|
713
|
-
const judgeText = await this.callJudge('captain-output-adjudication', judgePrompt, signal, input.stateId);
|
|
714
|
-
try {
|
|
715
|
-
return adjudicateCaptainOutput(input, result.finalText, judgeText);
|
|
212
|
+
return undefined;
|
|
213
|
+
};
|
|
214
|
+
const nonEmpty = (key) => typeof parsed[key] === 'string' && parsed[key].trim().length > 0
|
|
215
|
+
? undefined
|
|
216
|
+
: `the ${action} selection's \`${key}\` must be a non-empty string`;
|
|
217
|
+
switch (action) {
|
|
218
|
+
case 'respond': {
|
|
219
|
+
const shape = requireKeys(['text']) ?? nonEmpty('text');
|
|
220
|
+
if (shape !== undefined)
|
|
221
|
+
return { reason: shape };
|
|
222
|
+
return { selection: { action, text: parsed.text } };
|
|
223
|
+
}
|
|
224
|
+
case 'start':
|
|
225
|
+
case 'switch': {
|
|
226
|
+
const shape = requireKeys(['playbookId', 'input']) ??
|
|
227
|
+
nonEmpty('playbookId') ??
|
|
228
|
+
nonEmpty('input');
|
|
229
|
+
if (shape !== undefined)
|
|
230
|
+
return { reason: shape };
|
|
231
|
+
const playbookId = parsed.playbookId;
|
|
232
|
+
if (playbookId === selfPlaybookId) {
|
|
233
|
+
return {
|
|
234
|
+
reason: `the ${action} target may never be this Captain playbook itself`,
|
|
235
|
+
};
|
|
716
236
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
throw signal.reason;
|
|
722
|
-
const retryPrompt = makeJudgeRetryPrompt(judgePrompt, rejection);
|
|
723
|
-
const retryText = await this.callJudge('captain-output-adjudication', retryPrompt, signal, input.stateId);
|
|
724
|
-
return adjudicateCaptainOutput(input, result.finalText, retryText);
|
|
237
|
+
if (!options.enabledPlaybooks.some((entry) => entry.id === playbookId)) {
|
|
238
|
+
return {
|
|
239
|
+
reason: `the ${action} target ${JSON.stringify(playbookId)} is not an enabled catalog id`,
|
|
240
|
+
};
|
|
725
241
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
242
|
+
return {
|
|
243
|
+
selection: {
|
|
244
|
+
action,
|
|
245
|
+
playbookId,
|
|
246
|
+
input: parsed.input,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
case 'dismiss': {
|
|
251
|
+
const shape = requireKeys([]);
|
|
252
|
+
if (shape !== undefined)
|
|
253
|
+
return { reason: shape };
|
|
254
|
+
return { selection: { action } };
|
|
255
|
+
}
|
|
256
|
+
case 'deliver': {
|
|
257
|
+
// A deliver selection carries no text payload: the host is
|
|
258
|
+
// authoritative for the delivered text, so a carried `text` is
|
|
259
|
+
// ignored and never delivered (CAPPLAY-9).
|
|
260
|
+
const shape = requireKeys([], ['text']);
|
|
261
|
+
if (shape !== undefined)
|
|
262
|
+
return { reason: shape };
|
|
263
|
+
return { selection: { action } };
|
|
264
|
+
}
|
|
265
|
+
case 'runtime': {
|
|
266
|
+
const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
|
|
267
|
+
if (shape !== undefined)
|
|
268
|
+
return { reason: shape };
|
|
269
|
+
return { selection: { action, actionId: parsed.actionId } };
|
|
270
|
+
}
|
|
271
|
+
default:
|
|
272
|
+
return { reason: `the reply names no known action (${action})` };
|
|
732
273
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
let result;
|
|
755
|
-
let failure;
|
|
756
|
-
try {
|
|
757
|
-
result = await this.captainLane.add(async () => {
|
|
758
|
-
if (signal.aborted)
|
|
759
|
-
throw signal.reason;
|
|
760
|
-
const raw = await this.requireSession().ports.callCaptain(prompt, signal, CAPTAIN_OPTIONS);
|
|
761
|
-
if (signal.aborted)
|
|
762
|
-
throw signal.reason;
|
|
763
|
-
return validateCaptainResult(raw);
|
|
764
|
-
});
|
|
765
|
-
if (result.status !== 'ok') {
|
|
766
|
-
failure = new Error(result.error ?? `Captain returned ${result.status}`);
|
|
767
|
-
}
|
|
768
|
-
else if (!result.finalText) {
|
|
769
|
-
failure = new Error('Captain returned ok without finalText');
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
catch (error) {
|
|
773
|
-
failure = error;
|
|
774
|
-
}
|
|
775
|
-
const normalized = failure === undefined ? undefined : normalizeError(failure);
|
|
776
|
-
const abortedFailure = failure !== undefined && isSignalAbort(failure, signal);
|
|
777
|
-
const finishPayload = {
|
|
778
|
-
stateId: input.stateId,
|
|
779
|
-
sourceItem: input.sourceItem,
|
|
780
|
-
prompt,
|
|
781
|
-
visibility: 'visible',
|
|
782
|
-
resume: false,
|
|
783
|
-
allowedTools: [],
|
|
784
|
-
status: result?.status ?? (abortedFailure ? 'aborted' : 'error'),
|
|
785
|
-
...(result?.finalText === undefined ? {} : { finalText: result.finalText }),
|
|
786
|
-
...(result?.error === undefined ? {} : { error: result.error }),
|
|
787
|
-
...(normalized === undefined ? {} : { error: normalized }),
|
|
274
|
+
}
|
|
275
|
+
function selectionFromParsedDecision(decision) {
|
|
276
|
+
if (decision.action === 'deliver') {
|
|
277
|
+
return { action: 'deliver' };
|
|
278
|
+
}
|
|
279
|
+
// A parse-resolved `start` / `switch` is always the exact command remainder
|
|
280
|
+
// supplied by the host (CAPTAIN-7 command table).
|
|
281
|
+
return {
|
|
282
|
+
action: decision.action,
|
|
283
|
+
playbookId: decision.playbookId,
|
|
284
|
+
input: decision.input,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** The machine retains the selected standalone input and settlement evidence. */
|
|
288
|
+
function decisionOutputOf(selection) {
|
|
289
|
+
if (selection.action === 'start' || selection.action === 'switch') {
|
|
290
|
+
return {
|
|
291
|
+
guard: selection.action,
|
|
292
|
+
playbookId: selection.playbookId,
|
|
293
|
+
input: selection.input,
|
|
788
294
|
};
|
|
789
|
-
if (failure !== undefined) {
|
|
790
|
-
if (isSignalAbort(failure, signal)) {
|
|
791
|
-
await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
792
|
-
throw failure;
|
|
793
|
-
}
|
|
794
|
-
await this.tracePreservingError('captain.call.finished', finishPayload, failure, this.currentTraceTurnId(), callId);
|
|
795
|
-
throw failure;
|
|
796
|
-
}
|
|
797
|
-
await this.trace('captain.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
798
|
-
if (failure !== undefined)
|
|
799
|
-
throw failure;
|
|
800
|
-
if (!result)
|
|
801
|
-
throw new Error('Captain returned no result');
|
|
802
|
-
return result;
|
|
803
295
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
296
|
+
return { ...selection, guard: selection.action };
|
|
297
|
+
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Settlement validation: the returned settlement is the only evidence of
|
|
300
|
+
// effects, and the machine retains only its status, facts, receipt
|
|
301
|
+
// disposition, and leaf-state summary (CAPPLAY-10). A malformed settlement
|
|
302
|
+
// is a host control-plane failure.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
function validateSettlement(value) {
|
|
305
|
+
if (!isRecord(value)) {
|
|
306
|
+
throw new TypeError('controller settlement must be an object');
|
|
307
|
+
}
|
|
308
|
+
const allowed = new Set([
|
|
309
|
+
'status',
|
|
310
|
+
'facts',
|
|
311
|
+
'reason',
|
|
312
|
+
'receipt',
|
|
313
|
+
'leafStateSummary',
|
|
314
|
+
]);
|
|
315
|
+
for (const key of Object.keys(value)) {
|
|
316
|
+
if (!allowed.has(key)) {
|
|
317
|
+
throw new TypeError(`controller settlement carries undeclared ${key}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (value.status !== 'ok' &&
|
|
321
|
+
value.status !== 'rejected' &&
|
|
322
|
+
value.status !== 'failed') {
|
|
323
|
+
throw new TypeError(`controller settlement status must be ok | rejected | failed (got ${String(value.status)})`);
|
|
324
|
+
}
|
|
325
|
+
if (!Array.isArray(value.facts) ||
|
|
326
|
+
value.facts.some((fact) => typeof fact !== 'string')) {
|
|
327
|
+
throw new TypeError('controller settlement facts must be a string array');
|
|
328
|
+
}
|
|
329
|
+
if ('reason' in value && typeof value.reason !== 'string') {
|
|
330
|
+
throw new TypeError('controller settlement reason must be a string');
|
|
331
|
+
}
|
|
332
|
+
if ('leafStateSummary' in value &&
|
|
333
|
+
typeof value.leafStateSummary !== 'string') {
|
|
334
|
+
throw new TypeError('controller settlement leafStateSummary must be a string');
|
|
335
|
+
}
|
|
336
|
+
let receipt;
|
|
337
|
+
if ('receipt' in value) {
|
|
338
|
+
if (!isRecord(value.receipt)) {
|
|
339
|
+
throw new TypeError('controller settlement receipt must be an object');
|
|
340
|
+
}
|
|
341
|
+
const receiptAllowed = new Set(['disposition', 'reason', 'error']);
|
|
342
|
+
for (const key of Object.keys(value.receipt)) {
|
|
343
|
+
if (!receiptAllowed.has(key)) {
|
|
344
|
+
throw new TypeError(`controller settlement receipt carries undeclared ${key}`);
|
|
345
|
+
}
|
|
827
346
|
}
|
|
828
|
-
|
|
829
|
-
|
|
347
|
+
const disposition = value.receipt.disposition;
|
|
348
|
+
if (disposition !== 'executed' &&
|
|
349
|
+
disposition !== 'rejected' &&
|
|
350
|
+
disposition !== 'failed') {
|
|
351
|
+
throw new TypeError('controller settlement receipt disposition must be executed | rejected | failed');
|
|
830
352
|
}
|
|
831
|
-
if (
|
|
832
|
-
|
|
833
|
-
const finishPayload = omitUndefined({
|
|
834
|
-
purpose,
|
|
835
|
-
prompt,
|
|
836
|
-
stateId,
|
|
837
|
-
status: aborted ? 'aborted' : 'error',
|
|
838
|
-
error: normalizeError(failure),
|
|
839
|
-
});
|
|
840
|
-
if (aborted) {
|
|
841
|
-
await this.trace('judge.call.finished', finishPayload, this.currentTraceTurnId(), callId);
|
|
842
|
-
}
|
|
843
|
-
else {
|
|
844
|
-
await this.tracePreservingError('judge.call.finished', finishPayload, failure, this.currentTraceTurnId(), callId);
|
|
845
|
-
}
|
|
846
|
-
throw failure;
|
|
353
|
+
if ('reason' in value.receipt && typeof value.receipt.reason !== 'string') {
|
|
354
|
+
throw new TypeError('controller settlement receipt reason must be a string');
|
|
847
355
|
}
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
async classifyBossInput(text, state, signal) {
|
|
854
|
-
const pending = this.pendingQuestion();
|
|
855
|
-
const prompt = classifierPrompt(text, state, pending ? { questionId: pending.questionId, player: pending.player, question: pending.question } : undefined);
|
|
856
|
-
const reply = await this.callJudge('boss-input-classification', prompt, signal, state.stateId);
|
|
857
|
-
const event = validateClassifier(reply, text, pending?.questionId);
|
|
858
|
-
if (!event)
|
|
859
|
-
return undefined;
|
|
860
|
-
return event;
|
|
861
|
-
}
|
|
862
|
-
pendingQuestion() {
|
|
863
|
-
const snapshot = this.actor?.getSnapshot();
|
|
864
|
-
const context = snapshot?.context;
|
|
865
|
-
if (!isRecord(context) || !isRecord(context.pendingBossQuestion))
|
|
866
|
-
return undefined;
|
|
867
|
-
return {
|
|
868
|
-
questionId: assertNonEmptyString(context.pendingBossQuestion.questionId, 'pending question id'),
|
|
869
|
-
player: assertNonEmptyString(context.pendingBossQuestion.player, 'pending question player'),
|
|
870
|
-
question: assertNonEmptyString(context.pendingBossQuestion.question, 'pending question text'),
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
enqueueTransition(event, actor) {
|
|
874
|
-
const state = stateFromSnapshot(actor, this.nestedBridge?.getPendingCall());
|
|
875
|
-
const previousState = this.previousState ?? state;
|
|
876
|
-
this.previousState = state;
|
|
877
|
-
const transition = omitUndefined({
|
|
878
|
-
event: this.describeEvent(event),
|
|
879
|
-
from: previousState,
|
|
880
|
-
to: state,
|
|
881
|
-
previousState,
|
|
882
|
-
state,
|
|
883
|
-
stateId: state.stateId,
|
|
884
|
-
pendingBossQuestion: this.pendingQuestion(),
|
|
885
|
-
lastError: this.lastError(),
|
|
886
|
-
});
|
|
887
|
-
this.enqueue(async () => {
|
|
888
|
-
await this.traceNow('fsm.transition', transition, this.currentTraceTurnId());
|
|
889
|
-
await this.requireSession().ports.emitTelemetry({ topic: 'playbook.fsm.state', payload: transition });
|
|
890
|
-
if (state.stateId !== 'ready' && state.stateId !== 'done') {
|
|
891
|
-
await this.traceNow('status.emitted', omitUndefined({ message: `Entered ${state.stateId ?? 'state'}`, state, stateId: state.stateId }), this.currentTraceTurnId());
|
|
892
|
-
await this.requireSession().ports.emitStatus(`Entered ${state.stateId ?? 'state'}`, transition);
|
|
356
|
+
let error;
|
|
357
|
+
if ('error' in value.receipt) {
|
|
358
|
+
const compact = normalizeErrorCompact(value.receipt.error);
|
|
359
|
+
if (compact === undefined) {
|
|
360
|
+
throw new TypeError('controller settlement receipt error must be a normalized error');
|
|
893
361
|
}
|
|
362
|
+
error = compact;
|
|
363
|
+
}
|
|
364
|
+
receipt = Object.freeze({
|
|
365
|
+
disposition,
|
|
366
|
+
...(typeof value.receipt.reason === 'string'
|
|
367
|
+
? { reason: value.receipt.reason }
|
|
368
|
+
: {}),
|
|
369
|
+
...(error === undefined ? {} : { error: Object.freeze(error) }),
|
|
894
370
|
});
|
|
895
371
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
return
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
async emitStatus(message, data) {
|
|
927
|
-
const state = stateFromSnapshot(this.requireActor(), this.nestedBridge?.getPendingCall());
|
|
928
|
-
const payload = omitUndefined({ message, data, state, stateId: state.stateId });
|
|
929
|
-
await this.trace('status.emitted', payload, this.currentTraceTurnId());
|
|
930
|
-
await this.requireSession().ports.emitStatus(message, data);
|
|
931
|
-
}
|
|
932
|
-
async traceSettled(result, turnId) {
|
|
933
|
-
await this.trace('boss.input.settled', this.runResultPayload(result), turnId);
|
|
934
|
-
}
|
|
935
|
-
runResultPayload(result) {
|
|
936
|
-
return omitUndefined({
|
|
937
|
-
outcome: result.outcome,
|
|
938
|
-
state: result.state,
|
|
939
|
-
stateId: result.state.stateId,
|
|
940
|
-
pendingCall: 'pendingCall' in result ? result.pendingCall : undefined,
|
|
941
|
-
output: 'output' in result ? result.output : undefined,
|
|
942
|
-
error: 'error' in result ? result.error : undefined,
|
|
943
|
-
});
|
|
372
|
+
return Object.freeze({
|
|
373
|
+
status: value.status,
|
|
374
|
+
facts: Object.freeze([...value.facts]),
|
|
375
|
+
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
|
|
376
|
+
...(receipt === undefined ? {} : { receipt }),
|
|
377
|
+
...(typeof value.leafStateSummary === 'string'
|
|
378
|
+
? { leafStateSummary: value.leafStateSummary }
|
|
379
|
+
: {}),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// The controller captain-call strategy (slc/link.md §Captain adjudication,
|
|
384
|
+
// controller form). The engine composes the prompt, traces every call as its
|
|
385
|
+
// own captain.call pair on the shared serialized lane, and owns
|
|
386
|
+
// control-plane latching; this strategy owns the hidden calls, the decision
|
|
387
|
+
// validation with its one corrective re-ask, and the controller-port
|
|
388
|
+
// submission.
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
const HIDDEN = { visibility: 'hidden' };
|
|
391
|
+
async function callHidden(run, prompt) {
|
|
392
|
+
try {
|
|
393
|
+
return await run.callCaptain(prompt, HIDDEN);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
// DR-028: exactly one corrective re-ask of the same composed call on an
|
|
397
|
+
// empty `ok` result; a second such result follows the failure path from
|
|
398
|
+
// the boundary itself.
|
|
399
|
+
if (!run.isEmptyOkRetry(error))
|
|
400
|
+
throw error;
|
|
401
|
+
return await run.callCaptain(prompt, HIDDEN);
|
|
944
402
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
await this.drain();
|
|
403
|
+
}
|
|
404
|
+
function requireControllerPort(options) {
|
|
405
|
+
if (options.controller === undefined) {
|
|
406
|
+
throw new Error('captain.playbook: the host-supplied controller port is required to settle a Boss turn (CAPPLAY-9); construct the runtime with options.controller');
|
|
950
407
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
408
|
+
return options.controller;
|
|
409
|
+
}
|
|
410
|
+
async function submitSelection(run, selection) {
|
|
411
|
+
const port = requireControllerPort(run.options);
|
|
412
|
+
const settlement = validateSettlement(await port.submit(selection, run.signal));
|
|
413
|
+
run.signal.throwIfAborted();
|
|
414
|
+
return settlement;
|
|
415
|
+
}
|
|
416
|
+
async function runDecisionState(run) {
|
|
417
|
+
const input = run.input;
|
|
418
|
+
const declaredActions = new Set(Object.keys(run.input.result));
|
|
419
|
+
let selection;
|
|
420
|
+
if (input.parsedDecision !== undefined) {
|
|
421
|
+
// A parse-resolved turn enters with its decision already made: the
|
|
422
|
+
// injected decision object is the turn's decision and no decision call
|
|
423
|
+
// occurs (CAPTAIN-7, CAPPLAY-6). A malformed injection is a host bug —
|
|
424
|
+
// control-plane, not a corrective re-ask.
|
|
425
|
+
selection = selectionFromParsedDecision(validateParsedActingDecision(input.parsedDecision));
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const first = await callHidden(run, run.prompt);
|
|
429
|
+
const firstReading = readDecisionReply(first.finalText ?? '', run.options, run.session.playbookId, declaredActions);
|
|
430
|
+
if (firstReading.selection !== undefined) {
|
|
431
|
+
selection = firstReading.selection;
|
|
962
432
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
...(turnId === undefined ? {} : { turnId }),
|
|
978
|
-
...(callId === undefined ? {} : { callId }),
|
|
979
|
-
payload: snapshotJsonValue(payload, `trace ${type}`),
|
|
980
|
-
};
|
|
981
|
-
await session.ports.emitTelemetry({ topic: 'playbook.trace', payload: event });
|
|
982
|
-
}
|
|
983
|
-
enqueue(task) {
|
|
984
|
-
void this.emissionQueue.add(async () => {
|
|
985
|
-
try {
|
|
986
|
-
await task();
|
|
987
|
-
}
|
|
988
|
-
catch (error) {
|
|
989
|
-
this.latchControlError(error);
|
|
990
|
-
throw error;
|
|
433
|
+
else {
|
|
434
|
+
// CAPPLAY-18: exactly one corrective call appending the rejection
|
|
435
|
+
// reason and the restated reply contract to the same prompt.
|
|
436
|
+
const second = await callHidden(run, correctiveDecisionPrompt(run.prompt, firstReading.reason));
|
|
437
|
+
const secondReading = readDecisionReply(second.finalText ?? '', run.options, run.session.playbookId, declaredActions);
|
|
438
|
+
if (secondReading.selection === undefined) {
|
|
439
|
+
// A second malformed reply settles the turn as a Boss-appropriate
|
|
440
|
+
// failure reply with no action executed and the engagement stack
|
|
441
|
+
// untouched; the machine returns to its hub (CAPPLAY-18/19). The
|
|
442
|
+
// marker property routes the FSM's authored hub-recovery arm.
|
|
443
|
+
const failure = new Error(`the decision reply stayed malformed after one corrective re-ask: ${secondReading.reason}`);
|
|
444
|
+
failure.name = 'ControllerDecisionError';
|
|
445
|
+
failure.controllerDecisionFailure = true;
|
|
446
|
+
throw run.recoverableFailure(failure);
|
|
991
447
|
}
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
drain() {
|
|
995
|
-
return this.emissionQueue.onIdle().then(() => {
|
|
996
|
-
if (this.latchedControlError)
|
|
997
|
-
throw this.latchedControlError;
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
async bestEffortDisposeTrace(state) {
|
|
1001
|
-
try {
|
|
1002
|
-
this.disposalTraceEmitted = true;
|
|
1003
|
-
await this.trace('session.disposed', omitUndefined({ state, stateId: state.stateId }));
|
|
1004
|
-
await this.drain();
|
|
1005
|
-
}
|
|
1006
|
-
catch {
|
|
1007
|
-
// Preserve the original initialization error.
|
|
448
|
+
selection = secondReading.selection;
|
|
1008
449
|
}
|
|
1009
450
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
451
|
+
const settlement = await submitSelection(run, selection);
|
|
452
|
+
return {
|
|
453
|
+
...decisionOutputOf(selection),
|
|
454
|
+
settlement,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async function controllerCaptainStrategy(run) {
|
|
458
|
+
if (run.input.stateId === 'deciding') {
|
|
459
|
+
return (await runDecisionState(run));
|
|
460
|
+
}
|
|
461
|
+
// The prose states (answeringCommand, reporting): one hidden durable call
|
|
462
|
+
// whose validated text the host surfaces as captain speech through its
|
|
463
|
+
// presentation seam; the machine retains no reply prose (CAPPLAY-10) and
|
|
464
|
+
// the state settles with the default single-outcome `done` contract.
|
|
465
|
+
await callHidden(run, run.prompt);
|
|
466
|
+
return { guard: 'done' };
|
|
467
|
+
}
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Captain-pane status lines. The shell suppresses this runtime's human
|
|
470
|
+
// status stream while forwarding structured telemetry (CAPPLAY-9); the
|
|
471
|
+
// lines stay Boss-appropriate regardless. The parked hub and the shutdown
|
|
472
|
+
// state emit no human status (slc/link.md §Session lifecycle).
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
function statusesForState(state, context) {
|
|
475
|
+
switch (state.stateId) {
|
|
476
|
+
case 'deciding':
|
|
477
|
+
return [{ message: '⤷ Captain: decide the Boss turn' }];
|
|
478
|
+
case 'answeringCommand':
|
|
479
|
+
return [{ message: '⤷ Captain: answer the command turn' }];
|
|
480
|
+
case 'reporting':
|
|
481
|
+
return [{ message: '⤷ Captain: compose the closing reply' }];
|
|
482
|
+
case 'failed': {
|
|
483
|
+
const lastError = normalizeErrorCompact(context.lastError);
|
|
484
|
+
return [
|
|
485
|
+
{
|
|
486
|
+
message: '◆ failed',
|
|
487
|
+
...(lastError === undefined
|
|
488
|
+
? {}
|
|
489
|
+
: { data: snapshotJsonValue({ lastError }, 'failed status data') }),
|
|
490
|
+
},
|
|
491
|
+
];
|
|
492
|
+
}
|
|
493
|
+
default:
|
|
494
|
+
return [];
|
|
1050
495
|
}
|
|
1051
496
|
}
|
|
497
|
+
// Internal export surface for verification and tests. Not part of the
|
|
498
|
+
// stable public API; the leading underscore signals "subject to change."
|
|
1052
499
|
export const _internal = {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
500
|
+
// No placeholder exists in any compiled Captain prompt: the labeled
|
|
501
|
+
// Boss-message, ControlView digest, and catalog digest blocks are
|
|
502
|
+
// shell-composed inside the hidden-control envelope (CAPTAIN-9), so the
|
|
503
|
+
// shared default composer emits the verbatim GEARS domain body.
|
|
504
|
+
composeCaptainPrompt: (input) => defaultComposeCaptainPrompt(input),
|
|
505
|
+
classifyControllerTurn,
|
|
506
|
+
readDecisionReply,
|
|
507
|
+
correctiveDecisionPrompt,
|
|
508
|
+
validateSettlement,
|
|
509
|
+
validateParsedActingDecision,
|
|
510
|
+
statusesForState,
|
|
511
|
+
normalizeError,
|
|
512
|
+
};
|
|
513
|
+
// The Captain-specific spec handed to the shared runtime factory
|
|
514
|
+
// (slc/link.md §Output, DR-019). Generic machinery — actor wiring, boundary
|
|
515
|
+
// tracing, lifecycle, abort, the parked-session snapshot, and the DR-029
|
|
516
|
+
// describe/apply control surface — lives in @sublang/playbook/xstate-runtime.
|
|
517
|
+
const runtimeSpec = {
|
|
518
|
+
label: 'CAPTAIN',
|
|
519
|
+
compat: { artifactSchema: 1, runtimeAbi: RUNTIME_ABI },
|
|
520
|
+
snapshotOptions: snapshotCaptainOptions,
|
|
521
|
+
machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
|
|
522
|
+
classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) => classifyControllerTurn(text, ports, signal, snapshotOrState, boundary, options),
|
|
523
|
+
playerStates: {},
|
|
524
|
+
classificationStatus: () => undefined,
|
|
525
|
+
captainStrategy: controllerCaptainStrategy,
|
|
526
|
+
// CAPPLAY-10 / PBRT-52: the Captain's own ControlView context projection —
|
|
527
|
+
// the settlement evidence the machine retains for its decision and reply
|
|
528
|
+
// phases, and nothing else. `enabledPlaybooks` is the host-supplied
|
|
529
|
+
// catalog (an option value), and `parsedDecision` and `lastError` are
|
|
530
|
+
// control-plane scratch; none of the three is settlement evidence, so none
|
|
531
|
+
// is exported. Declaring the list here is what makes "Captain-visible
|
|
532
|
+
// context" an enumerated set rather than whatever the FSM happens to hold.
|
|
533
|
+
controlContextFields: [
|
|
534
|
+
'bossText',
|
|
535
|
+
'selectedAction',
|
|
536
|
+
'settlementStatus',
|
|
537
|
+
'settlementFacts',
|
|
538
|
+
'settlementReason',
|
|
539
|
+
'receiptDisposition',
|
|
540
|
+
'receiptReason',
|
|
541
|
+
'receiptError',
|
|
542
|
+
'leafStateSummary',
|
|
543
|
+
],
|
|
544
|
+
statusesForState,
|
|
1056
545
|
};
|
|
546
|
+
const createCaptainPlaybookRuntime = createXStatePlaybookRuntime(captainMachine, runtimeSpec);
|
|
1057
547
|
export function createPlaybookRuntime(options) {
|
|
1058
|
-
return
|
|
548
|
+
return createCaptainPlaybookRuntime(options);
|
|
1059
549
|
}
|
|
1060
550
|
const factory = createPlaybookRuntime;
|
|
1061
551
|
export default factory;
|