@reactive-skills/runtime 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/cli/dev.d.ts +2 -0
- package/dist/cli/dev.js +114 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +215 -0
- package/dist/core/event-store.d.ts +182 -0
- package/dist/core/event-store.js +762 -0
- package/dist/core/fsm-engine.d.ts +94 -0
- package/dist/core/fsm-engine.js +648 -0
- package/dist/core/guard-evaluator.d.ts +19 -0
- package/dist/core/guard-evaluator.js +103 -0
- package/dist/core/legacy-adapter.d.ts +27 -0
- package/dist/core/legacy-adapter.js +126 -0
- package/dist/core/migration.d.ts +18 -0
- package/dist/core/migration.js +256 -0
- package/dist/core/projection-engine.d.ts +43 -0
- package/dist/core/projection-engine.js +167 -0
- package/dist/core/runtime-hooks.d.ts +51 -0
- package/dist/core/runtime-hooks.js +195 -0
- package/dist/core/types.d.ts +238 -0
- package/dist/core/types.js +55 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +453 -0
- package/dist/sync/cli.d.ts +1 -0
- package/dist/sync/cli.js +186 -0
- package/dist/sync/engine.d.ts +2 -0
- package/dist/sync/engine.js +366 -0
- package/dist/sync/types.d.ts +35 -0
- package/dist/sync/types.js +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import Handlebars from 'handlebars';
|
|
5
|
+
import { SkillManifestSchema, } from './types.js';
|
|
6
|
+
import { EventStore, createSortableId } from './event-store.js';
|
|
7
|
+
import { GuardEvaluator } from './guard-evaluator.js';
|
|
8
|
+
import { LegacySkillAdapter } from './legacy-adapter.js';
|
|
9
|
+
import { ProjectionEngine } from './projection-engine.js';
|
|
10
|
+
/** Maximum lifecycle-signal drain steps per top-level handleSignal call.
|
|
11
|
+
* Prevents cyclic on_enter/on_exit emissions from overflowing the call stack (REL-02). */
|
|
12
|
+
const MAX_QUEUE_DRAIN_DEPTH = 50;
|
|
13
|
+
export class FSMEngine {
|
|
14
|
+
skillDir;
|
|
15
|
+
workspaceDir;
|
|
16
|
+
manifest;
|
|
17
|
+
activeStatePath = [];
|
|
18
|
+
context;
|
|
19
|
+
eventStore;
|
|
20
|
+
projectionEngine;
|
|
21
|
+
signalQueue = [];
|
|
22
|
+
isProcessingQueue = false;
|
|
23
|
+
signalQueueDepth = 0;
|
|
24
|
+
turnsSinceLastSignal;
|
|
25
|
+
inBypassState;
|
|
26
|
+
strictExecution;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.skillDir = path.resolve(options.skillDir);
|
|
29
|
+
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
30
|
+
this.manifest = this.loadManifest();
|
|
31
|
+
this.strictExecution = this.manifest.strict_execution === true;
|
|
32
|
+
this.turnsSinceLastSignal = 0;
|
|
33
|
+
this.inBypassState = false;
|
|
34
|
+
this.eventStore = options.eventStore || new EventStore({
|
|
35
|
+
skillId: this.manifest.name,
|
|
36
|
+
workspaceDir: this.workspaceDir,
|
|
37
|
+
runId: options.runId,
|
|
38
|
+
enableSqlite: true,
|
|
39
|
+
...options.eventContext,
|
|
40
|
+
});
|
|
41
|
+
this.context = {
|
|
42
|
+
...(this.manifest.default_context || {}),
|
|
43
|
+
...(options.initialContext || {}),
|
|
44
|
+
};
|
|
45
|
+
this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd());
|
|
46
|
+
const autoRehydrate = options.autoRehydrate !== false;
|
|
47
|
+
const latestSnapshot = autoRehydrate ? this.eventStore.getLatestSnapshot() : null;
|
|
48
|
+
const history = autoRehydrate
|
|
49
|
+
? (latestSnapshot ? this.eventStore.getSince(latestSnapshot.seq) : this.eventStore.getAll())
|
|
50
|
+
: [];
|
|
51
|
+
if (autoRehydrate && (latestSnapshot || history.length > 0)) {
|
|
52
|
+
this.rehydrate(history, latestSnapshot);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const initialPath = this.resolveInitialPath([this.manifest.initial_state]);
|
|
56
|
+
this.activeStatePath = [...initialPath];
|
|
57
|
+
this.eventStore.append('SKILL_INITIALIZED', {
|
|
58
|
+
skill: this.manifest.name,
|
|
59
|
+
initial_state: initialPath.join('.'),
|
|
60
|
+
active_path: initialPath,
|
|
61
|
+
context: this.context,
|
|
62
|
+
}, { state: initialPath.join('.') });
|
|
63
|
+
this.enterPath(initialPath, [], 'INITIAL_BOOT');
|
|
64
|
+
this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Rehydrate state machine state and context from immutable event history,
|
|
69
|
+
* resuming from the latest point-in-time state snapshot if available (PERF-02 / INV-08)
|
|
70
|
+
*/
|
|
71
|
+
rehydrate(events, snapshot) {
|
|
72
|
+
const snap = snapshot !== undefined ? snapshot : this.eventStore.getLatestSnapshot();
|
|
73
|
+
let latestPath;
|
|
74
|
+
let eventsToReplay = events;
|
|
75
|
+
if (snap) {
|
|
76
|
+
latestPath = snap.state.split('.');
|
|
77
|
+
this.context = { ...this.context, ...snap.context };
|
|
78
|
+
eventsToReplay = events.filter(e => e.seq > snap.seq);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
latestPath = this.resolveInitialPath([this.manifest.initial_state]);
|
|
82
|
+
}
|
|
83
|
+
for (const e of eventsToReplay) {
|
|
84
|
+
if (e.type === 'SKILL_INITIALIZED' && e.payload?.active_path) {
|
|
85
|
+
latestPath = e.payload.active_path;
|
|
86
|
+
if (e.payload.context) {
|
|
87
|
+
this.context = { ...this.context, ...e.payload.context };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (e.type === 'STATE_TRANSITION' && e.payload?.to) {
|
|
91
|
+
latestPath = e.payload.to.split('.');
|
|
92
|
+
}
|
|
93
|
+
else if (e.type === 'STATE_ENTRY_HOOK' && e.payload?.action?.set_context) {
|
|
94
|
+
this.updateContext(e.payload.action.set_context);
|
|
95
|
+
}
|
|
96
|
+
else if (e.type === 'SIGNAL_EMITTED') {
|
|
97
|
+
if (this.manifest.context_keys) {
|
|
98
|
+
const incomingContextUpdates = {};
|
|
99
|
+
for (const key of this.manifest.context_keys) {
|
|
100
|
+
if (e.payload[key] !== undefined) {
|
|
101
|
+
incomingContextUpdates[key] = e.payload[key];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(incomingContextUpdates).length > 0) {
|
|
105
|
+
this.updateContext(incomingContextUpdates);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (e.payload?.contextUpdates) {
|
|
109
|
+
this.updateContext(e.payload.contextUpdates);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (e.payload?.contextUpdates) {
|
|
113
|
+
this.updateContext(e.payload.contextUpdates);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.activeStatePath = latestPath;
|
|
117
|
+
// Recompute turn counter from event history
|
|
118
|
+
this.turnsSinceLastSignal = 0;
|
|
119
|
+
this.inBypassState = false;
|
|
120
|
+
for (const e of eventsToReplay) {
|
|
121
|
+
if (e.type === 'AGENT_TURN_STARTED') {
|
|
122
|
+
this.turnsSinceLastSignal++;
|
|
123
|
+
}
|
|
124
|
+
else if (e.type === 'SIGNAL_EMITTED' || e.type === 'STATE_TRANSITION') {
|
|
125
|
+
this.turnsSinceLastSignal = 0;
|
|
126
|
+
}
|
|
127
|
+
if (e.type === 'STATE_TRANSITION' && e.payload?.to === 'BYPASS_DETECTED') {
|
|
128
|
+
this.inBypassState = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Restore turns_since_last_signal from snapshot context if available
|
|
132
|
+
if (snap?.context?.turns_since_last_signal !== undefined) {
|
|
133
|
+
this.turnsSinceLastSignal = snap.context.turns_since_last_signal;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
loadManifest() {
|
|
137
|
+
const yamlPath = path.join(this.skillDir, 'skill.yaml');
|
|
138
|
+
if (!fs.existsSync(yamlPath)) {
|
|
139
|
+
throw new Error(`Reactive skill manifest not found: ${yamlPath}`);
|
|
140
|
+
}
|
|
141
|
+
const raw = fs.readFileSync(yamlPath, 'utf8');
|
|
142
|
+
const parsed = yaml.load(raw);
|
|
143
|
+
const validated = SkillManifestSchema.parse(parsed);
|
|
144
|
+
return validated;
|
|
145
|
+
}
|
|
146
|
+
getManifest() {
|
|
147
|
+
return this.manifest;
|
|
148
|
+
}
|
|
149
|
+
getCurrentState() {
|
|
150
|
+
return this.activeStatePath.join('.');
|
|
151
|
+
}
|
|
152
|
+
getActiveStatePath() {
|
|
153
|
+
return [...this.activeStatePath];
|
|
154
|
+
}
|
|
155
|
+
getContext() {
|
|
156
|
+
return { ...this.context };
|
|
157
|
+
}
|
|
158
|
+
updateContext(updates) {
|
|
159
|
+
this.context = { ...this.context, ...updates };
|
|
160
|
+
}
|
|
161
|
+
getEventStore() {
|
|
162
|
+
return this.eventStore;
|
|
163
|
+
}
|
|
164
|
+
getEventContext() {
|
|
165
|
+
return this.eventStore.getEventContext();
|
|
166
|
+
}
|
|
167
|
+
isStrictExecution() {
|
|
168
|
+
return this.strictExecution;
|
|
169
|
+
}
|
|
170
|
+
getTurnsSinceLastSignal() {
|
|
171
|
+
return this.turnsSinceLastSignal;
|
|
172
|
+
}
|
|
173
|
+
isBypassDetected() {
|
|
174
|
+
return this.inBypassState;
|
|
175
|
+
}
|
|
176
|
+
recordTurnStart() {
|
|
177
|
+
if (!this.strictExecution || this.inBypassState)
|
|
178
|
+
return;
|
|
179
|
+
this.eventStore.append('AGENT_TURN_STARTED', {
|
|
180
|
+
state: this.getCurrentState(),
|
|
181
|
+
turns_since_last_signal: this.turnsSinceLastSignal + 1,
|
|
182
|
+
}, { state: this.getCurrentState() });
|
|
183
|
+
this.turnsSinceLastSignal++;
|
|
184
|
+
this.checkBypass();
|
|
185
|
+
}
|
|
186
|
+
checkBypass() {
|
|
187
|
+
if (!this.strictExecution || this.inBypassState)
|
|
188
|
+
return;
|
|
189
|
+
const stateDef = this.getStateDefinition(this.activeStatePath);
|
|
190
|
+
const maxIdleTurns = stateDef?.max_idle_turns ?? 2;
|
|
191
|
+
if (this.turnsSinceLastSignal > maxIdleTurns) {
|
|
192
|
+
const bypassTarget = stateDef?.bypass_target ?? 'BYPASS_DETECTED';
|
|
193
|
+
this.eventStore.append('BYPASS_DETECTED', {
|
|
194
|
+
reason: 'Agent exceeded max idle turns without emitting a signal',
|
|
195
|
+
turns: this.turnsSinceLastSignal,
|
|
196
|
+
max_idle_turns: maxIdleTurns,
|
|
197
|
+
state: this.getCurrentState(),
|
|
198
|
+
}, { state: bypassTarget });
|
|
199
|
+
this.eventStore.append('STATE_TRANSITION', {
|
|
200
|
+
from: this.getCurrentState(),
|
|
201
|
+
to: bypassTarget,
|
|
202
|
+
signal: 'BYPASS_DETECTED',
|
|
203
|
+
payload: { reason: 'Agent exceeded max idle turns without emitting a signal' },
|
|
204
|
+
}, { state: bypassTarget });
|
|
205
|
+
this.inBypassState = true;
|
|
206
|
+
this.activeStatePath = [bypassTarget];
|
|
207
|
+
this.turnsSinceLastSignal = 0;
|
|
208
|
+
throw new Error(`BYPASS_DETECTED: Agent exceeded ${maxIdleTurns} idle turns without emitting a signal. ` +
|
|
209
|
+
`The runtime has entered the BYPASS_DETECTED state. ` +
|
|
210
|
+
`To recover, run: reactive-skills-axi reset ${this.manifest.name} then re-invoke.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
recordChildRunStarted(childSkillId, childRunId, requestId) {
|
|
214
|
+
return this.eventStore.append('CHILD_RUN_STARTED', {
|
|
215
|
+
child_skill_id: childSkillId,
|
|
216
|
+
child_run_id: childRunId,
|
|
217
|
+
request_id: requestId,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
recordChildRunCompleted(summary) {
|
|
221
|
+
const eventType = summary.outcome === 'failed' ? 'CHILD_RUN_FAILED' : 'CHILD_RUN_COMPLETED';
|
|
222
|
+
return this.eventStore.append(eventType, summary);
|
|
223
|
+
}
|
|
224
|
+
async invokeSkill(skillNameOrPath) {
|
|
225
|
+
const skillDir = path.resolve(this.workspaceDir || process.cwd(), skillNameOrPath);
|
|
226
|
+
const skillYamlPath = path.join(skillDir, 'skill.yaml');
|
|
227
|
+
let manifest;
|
|
228
|
+
if (fs.existsSync(skillYamlPath)) {
|
|
229
|
+
const raw = fs.readFileSync(skillYamlPath, 'utf8');
|
|
230
|
+
const parsed = yaml.load(raw);
|
|
231
|
+
manifest = SkillManifestSchema.parse(parsed);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
235
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
236
|
+
throw new Error(`Skill not found: ${skillNameOrPath} (no skill.yaml or SKILL.md at ${skillDir})`);
|
|
237
|
+
}
|
|
238
|
+
manifest = LegacySkillAdapter.wrapAsReactiveManifest(skillMdPath);
|
|
239
|
+
}
|
|
240
|
+
const childRunId = createSortableId();
|
|
241
|
+
const startEvent = this.recordChildRunStarted(manifest.name, childRunId);
|
|
242
|
+
let outcome = 'completed';
|
|
243
|
+
let childState = manifest.initial_state;
|
|
244
|
+
let promptSlice;
|
|
245
|
+
try {
|
|
246
|
+
const childEngine = new FSMEngine({
|
|
247
|
+
skillDir,
|
|
248
|
+
workspaceDir: this.workspaceDir,
|
|
249
|
+
runId: childRunId,
|
|
250
|
+
eventContext: {
|
|
251
|
+
skill_id: manifest.name,
|
|
252
|
+
parent_run_id: this.getEventContext().run_id,
|
|
253
|
+
},
|
|
254
|
+
initialContext: this.context,
|
|
255
|
+
});
|
|
256
|
+
childState = childEngine.getCurrentState();
|
|
257
|
+
promptSlice = childEngine.generatePromptSlice();
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
outcome = 'failed';
|
|
261
|
+
promptSlice = {
|
|
262
|
+
state: manifest.initial_state,
|
|
263
|
+
rawPrompt: `Failed to invoke skill: ${err instanceof Error ? err.message : String(err)}`,
|
|
264
|
+
formattedXml: '',
|
|
265
|
+
allowedTools: [],
|
|
266
|
+
context: {},
|
|
267
|
+
exitConditions: [],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
this.recordChildRunCompleted({
|
|
271
|
+
child_skill_id: manifest.name,
|
|
272
|
+
child_run_id: childRunId,
|
|
273
|
+
outcome,
|
|
274
|
+
failed_state: outcome === 'failed' ? childState : undefined,
|
|
275
|
+
summary: `Invoked ${manifest.name} at state ${childState}`,
|
|
276
|
+
evidence_ref: startEvent.id,
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
skillId: manifest.name,
|
|
280
|
+
currentState: childState,
|
|
281
|
+
promptSlice,
|
|
282
|
+
event: startEvent,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
recordDecision(decision, source = 'human_ingress') {
|
|
286
|
+
return this.eventStore.append('DECISION_RECORDED', decision, { source });
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Resolves full hierarchical path including default/initial substates
|
|
290
|
+
*/
|
|
291
|
+
resolveInitialPath(basePath) {
|
|
292
|
+
const fullPath = [...basePath];
|
|
293
|
+
let currentDef = this.getStateDefinition(fullPath);
|
|
294
|
+
while (currentDef && currentDef.initial_substate && currentDef.substates) {
|
|
295
|
+
const nextSub = currentDef.initial_substate;
|
|
296
|
+
fullPath.push(nextSub);
|
|
297
|
+
currentDef = currentDef.substates[nextSub];
|
|
298
|
+
}
|
|
299
|
+
return fullPath;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Get StateDefinition by path hierarchy
|
|
303
|
+
*/
|
|
304
|
+
getStateDefinition(pathSegments) {
|
|
305
|
+
if (pathSegments.length === 0)
|
|
306
|
+
return null;
|
|
307
|
+
const rootName = pathSegments[0];
|
|
308
|
+
let current = this.manifest.states[rootName];
|
|
309
|
+
if (!current)
|
|
310
|
+
return null;
|
|
311
|
+
for (let i = 1; i < pathSegments.length; i++) {
|
|
312
|
+
const seg = pathSegments[i];
|
|
313
|
+
if (!current.substates || !current.substates[seg]) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
current = current.substates[seg];
|
|
317
|
+
}
|
|
318
|
+
return current;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Generates the prompt slice for the active state hierarchy
|
|
322
|
+
*/
|
|
323
|
+
generatePromptSlice() {
|
|
324
|
+
const activeLeaf = this.getStateDefinition(this.activeStatePath);
|
|
325
|
+
if (!activeLeaf) {
|
|
326
|
+
throw new Error(`Active state definition not found: ${this.getCurrentState()}`);
|
|
327
|
+
}
|
|
328
|
+
// Find prompt template: check leaf first, then fallback to parent if leaf has none
|
|
329
|
+
let templatePath = null;
|
|
330
|
+
for (let i = this.activeStatePath.length; i >= 1; i--) {
|
|
331
|
+
const depthPath = this.activeStatePath.slice(0, i);
|
|
332
|
+
const def = this.getStateDefinition(depthPath);
|
|
333
|
+
// 1. Explicit prompt_template path
|
|
334
|
+
if (def?.prompt_template) {
|
|
335
|
+
const candidate = path.resolve(this.skillDir, def.prompt_template);
|
|
336
|
+
if (fs.existsSync(candidate)) {
|
|
337
|
+
templatePath = candidate;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// 2. Convention over configuration fallback: states/<leaf_name_lowercase>.md
|
|
342
|
+
const leafName = depthPath[depthPath.length - 1].toLowerCase();
|
|
343
|
+
const conventionCandidates = [
|
|
344
|
+
path.resolve(this.skillDir, 'states', `${leafName}.md`),
|
|
345
|
+
path.resolve(this.skillDir, 'states', `${depthPath.join('_').toLowerCase()}.md`),
|
|
346
|
+
path.resolve(this.skillDir, `${leafName}.md`),
|
|
347
|
+
];
|
|
348
|
+
for (const candidate of conventionCandidates) {
|
|
349
|
+
if (fs.existsSync(candidate)) {
|
|
350
|
+
templatePath = candidate;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (templatePath)
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
let rawPrompt = '';
|
|
358
|
+
if (templatePath && fs.existsSync(templatePath)) {
|
|
359
|
+
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
|
360
|
+
const compiled = Handlebars.compile(templateContent);
|
|
361
|
+
rawPrompt = compiled({
|
|
362
|
+
state: this.getCurrentState(),
|
|
363
|
+
activeStatePath: this.activeStatePath,
|
|
364
|
+
context: this.context,
|
|
365
|
+
manifest: this.manifest,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
rawPrompt = `Execute instructions for state: ${this.getCurrentState()}`;
|
|
370
|
+
}
|
|
371
|
+
// Aggregate allowed tools up the active ancestor hierarchy
|
|
372
|
+
const allowedToolsSet = new Set();
|
|
373
|
+
for (let i = 1; i <= this.activeStatePath.length; i++) {
|
|
374
|
+
const def = this.getStateDefinition(this.activeStatePath.slice(0, i));
|
|
375
|
+
if (def?.tools) {
|
|
376
|
+
for (const tool of def.tools) {
|
|
377
|
+
allowedToolsSet.add(tool);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const allowedTools = Array.from(allowedToolsSet);
|
|
382
|
+
// Aggregate available exit transitions
|
|
383
|
+
const exitConditions = [];
|
|
384
|
+
for (let i = this.activeStatePath.length; i >= 1; i--) {
|
|
385
|
+
const depthPath = this.activeStatePath.slice(0, i);
|
|
386
|
+
const def = this.getStateDefinition(depthPath);
|
|
387
|
+
if (def?.transitions) {
|
|
388
|
+
for (const [signal, trans] of Object.entries(def.transitions)) {
|
|
389
|
+
const transDef = typeof trans === 'string' ? { target: trans } : trans;
|
|
390
|
+
exitConditions.push(`[${depthPath.join('.')}] On signal '${signal}' -> transition to '${transDef.target}'${transDef.guard ? ` (guard: ${transDef.guard})` : ''}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const humanGate = activeLeaf.human_gate;
|
|
395
|
+
let humanGateXml = '';
|
|
396
|
+
if (humanGate) {
|
|
397
|
+
humanGateXml = [
|
|
398
|
+
` <human_gate type="${humanGate.type}" tool="${humanGate.tool || 'ask_question'}">`,
|
|
399
|
+
` <instruction>This state requires human input/approval. Use ${humanGate.tool || 'ask_question'} or the review surface to collect user decision, then STOP calling tools to conclude your turn.</instruction>`,
|
|
400
|
+
humanGate.options ? ` <options>${humanGate.options.join(' | ')}</options>` : '',
|
|
401
|
+
` </human_gate>`,
|
|
402
|
+
].filter(Boolean).join('\n');
|
|
403
|
+
}
|
|
404
|
+
const formattedXml = [
|
|
405
|
+
`<reactive_skill_state name="${this.getCurrentState()}" path="${this.activeStatePath.join('/')}" skill="${this.manifest.name}">`,
|
|
406
|
+
this.strictExecution ? ' <strict_execution mode="enforced">' : '',
|
|
407
|
+
' <contract>TODO Card: Load -> Execute -> Emit</contract>',
|
|
408
|
+
' <contract>Every agent turn must produce a signal via reactive_emit_signal. Fetching state without emitting a signal counts against the idle budget.</contract>',
|
|
409
|
+
` <contract>Max idle turns: ${this.getStateDefinition(this.activeStatePath)?.max_idle_turns ?? 2}. Exceeding this triggers auto-abort to BYPASS_DETECTED.</contract>`,
|
|
410
|
+
' <contract>Allowed tools are enforced. Tools outside the allowed list will trigger bypass detection in interceptor mode.</contract>',
|
|
411
|
+
' <contract>Recovery: reactive-skills-axi reset <skill> then re-invoke.</contract>',
|
|
412
|
+
' </strict_execution>',
|
|
413
|
+
` <state_goal>`,
|
|
414
|
+
rawPrompt.trim().split('\n').map(line => ` ${line}`).join('\n'),
|
|
415
|
+
` </state_goal>`,
|
|
416
|
+
` <allowed_tools>`,
|
|
417
|
+
allowedTools.map(t => ` <tool name="${t}" />`).join('\n'),
|
|
418
|
+
` </allowed_tools>`,
|
|
419
|
+
humanGateXml,
|
|
420
|
+
` <transition_contracts>`,
|
|
421
|
+
exitConditions.map(c => ` <contract>${c}</contract>`).join('\n'),
|
|
422
|
+
` </transition_contracts>`,
|
|
423
|
+
`</reactive_skill_state>`,
|
|
424
|
+
].filter(Boolean).join('\n');
|
|
425
|
+
return {
|
|
426
|
+
state: this.getCurrentState(),
|
|
427
|
+
rawPrompt,
|
|
428
|
+
formattedXml,
|
|
429
|
+
allowedTools,
|
|
430
|
+
context: { ...this.context },
|
|
431
|
+
exitConditions,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Check if current state has a blocking Human-in-the-Loop gate
|
|
436
|
+
*/
|
|
437
|
+
isWaitingForHuman() {
|
|
438
|
+
const activeLeaf = this.getStateDefinition(this.activeStatePath);
|
|
439
|
+
return Boolean(activeLeaf?.human_gate);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Process an incoming signal event, evaluating transitions with HSM bubbling and draining queued signals
|
|
443
|
+
*/
|
|
444
|
+
async handleSignal(signalName, payload = {}, metadata = {}) {
|
|
445
|
+
const previousState = this.getCurrentState();
|
|
446
|
+
const prevPath = [...this.activeStatePath];
|
|
447
|
+
const event = this.eventStore.append('SIGNAL_EMITTED', { signal: signalName, ...payload }, { source: metadata.source, causationId: metadata.causationId, state: previousState });
|
|
448
|
+
this.turnsSinceLastSignal = 0;
|
|
449
|
+
// Merge incoming payload fields that match context_keys into the context
|
|
450
|
+
if (this.manifest.context_keys) {
|
|
451
|
+
const incomingContextUpdates = {};
|
|
452
|
+
for (const key of this.manifest.context_keys) {
|
|
453
|
+
if (payload[key] !== undefined) {
|
|
454
|
+
incomingContextUpdates[key] = payload[key];
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (Object.keys(incomingContextUpdates).length > 0) {
|
|
458
|
+
this.updateContext(incomingContextUpdates);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Bubble search: test from deepest leaf substate up to root
|
|
462
|
+
for (let depth = this.activeStatePath.length; depth >= 1; depth--) {
|
|
463
|
+
const testPath = this.activeStatePath.slice(0, depth);
|
|
464
|
+
const stateDef = this.getStateDefinition(testPath);
|
|
465
|
+
if (stateDef?.transitions && stateDef.transitions[signalName]) {
|
|
466
|
+
const transRaw = stateDef.transitions[signalName];
|
|
467
|
+
const transDef = typeof transRaw === 'string' ? { target: transRaw } : transRaw;
|
|
468
|
+
if (depth < this.activeStatePath.length) {
|
|
469
|
+
this.eventStore.append('EVENT_BUBBLED', {
|
|
470
|
+
signal: signalName,
|
|
471
|
+
fromLeaf: previousState,
|
|
472
|
+
handledAt: testPath.join('.'),
|
|
473
|
+
depth,
|
|
474
|
+
}, { state: previousState, causationId: event.id });
|
|
475
|
+
}
|
|
476
|
+
// Evaluate Guard
|
|
477
|
+
const guardResult = await GuardEvaluator.evaluate(transDef.guard, transDef.guardFunction, {
|
|
478
|
+
event,
|
|
479
|
+
context: this.context,
|
|
480
|
+
currentState: testPath.join('.'),
|
|
481
|
+
skillDir: this.skillDir,
|
|
482
|
+
});
|
|
483
|
+
this.eventStore.append('GUARD_EVALUATED', {
|
|
484
|
+
guard: transDef.guard || transDef.guardFunction || 'true',
|
|
485
|
+
passed: guardResult.passed,
|
|
486
|
+
target: transDef.target,
|
|
487
|
+
handledAt: testPath.join('.'),
|
|
488
|
+
error: guardResult.error,
|
|
489
|
+
}, { state: testPath.join('.'), causationId: event.id });
|
|
490
|
+
if (guardResult.passed) {
|
|
491
|
+
// Auto-invoke child skill if transition declares it
|
|
492
|
+
if (transDef.invoke) {
|
|
493
|
+
await this.invokeSkill(transDef.invoke);
|
|
494
|
+
}
|
|
495
|
+
// Parse target path (supports dot notation, e.g. "REFACTOR.EXTRACT_METHOD" or "GREEN_CODE")
|
|
496
|
+
const targetSegments = transDef.target.split('.');
|
|
497
|
+
const fullTargetPath = this.resolveInitialPath(targetSegments);
|
|
498
|
+
// Execute exit hooks and entry hooks along the transition path
|
|
499
|
+
this.transitionBetweenPaths(prevPath, fullTargetPath, signalName, event.id, payload);
|
|
500
|
+
// PERF-02 / INV-08: Persist state snapshot for fast cold-boot rehydration
|
|
501
|
+
this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
|
|
502
|
+
// Render deliverables
|
|
503
|
+
const deliverablesWritten = this.projectionEngine.project(this.eventStore, this.getCurrentState(), this.manifest.name, this.context, signalName);
|
|
504
|
+
// Drain queued lifecycle signals with cycle-depth guard (REL-02)
|
|
505
|
+
if (!this.isProcessingQueue && this.signalQueue.length > 0) {
|
|
506
|
+
this.isProcessingQueue = true;
|
|
507
|
+
this.signalQueueDepth = 0;
|
|
508
|
+
try {
|
|
509
|
+
while (this.signalQueue.length > 0) {
|
|
510
|
+
this.signalQueueDepth += 1;
|
|
511
|
+
if (this.signalQueueDepth > MAX_QUEUE_DRAIN_DEPTH) {
|
|
512
|
+
const cycleError = new RangeError(`Signal queue cycle detected: exceeded ${MAX_QUEUE_DRAIN_DEPTH} drain steps. ` +
|
|
513
|
+
`Check on_enter/on_exit lifecycle hooks for directed cycles in skill '${this.manifest.name}'.`);
|
|
514
|
+
this.eventStore.append('SIGNAL_QUEUE_CYCLE_DETECTED', {
|
|
515
|
+
depth: this.signalQueueDepth,
|
|
516
|
+
skill: this.manifest.name,
|
|
517
|
+
pendingSignals: this.signalQueue.map(s => s.signal),
|
|
518
|
+
});
|
|
519
|
+
this.signalQueue = [];
|
|
520
|
+
throw cycleError;
|
|
521
|
+
}
|
|
522
|
+
const next = this.signalQueue.shift();
|
|
523
|
+
await this.handleSignal(next.signal, next.payload || {}, next.metadata || {});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
this.isProcessingQueue = false;
|
|
528
|
+
this.signalQueueDepth = 0;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
transitioned: true,
|
|
533
|
+
previousState,
|
|
534
|
+
newState: this.getCurrentState(),
|
|
535
|
+
event,
|
|
536
|
+
handledAtDepth: depth,
|
|
537
|
+
deliverablesWritten,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
transitioned: false,
|
|
544
|
+
previousState,
|
|
545
|
+
newState: previousState,
|
|
546
|
+
event,
|
|
547
|
+
deliverablesWritten: [],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Transition between two hierarchical paths executing exit, transition, and entry hooks
|
|
552
|
+
*/
|
|
553
|
+
transitionBetweenPaths(fromPath, toPath, signalName, causationId, payload) {
|
|
554
|
+
// 1. Find Lowest Common Ancestor (LCA)
|
|
555
|
+
let lcaDepth = 0;
|
|
556
|
+
const maxDepth = Math.min(fromPath.length, toPath.length);
|
|
557
|
+
while (lcaDepth < maxDepth && fromPath[lcaDepth] === toPath[lcaDepth]) {
|
|
558
|
+
lcaDepth++;
|
|
559
|
+
}
|
|
560
|
+
// 2. Exit states from leaf up to LCA (exclusive)
|
|
561
|
+
for (let i = fromPath.length; i > lcaDepth; i--) {
|
|
562
|
+
const exitSubpath = fromPath.slice(0, i);
|
|
563
|
+
this.executeExitHook(exitSubpath, signalName);
|
|
564
|
+
}
|
|
565
|
+
// 3. Record STATE_TRANSITION event in EventStore
|
|
566
|
+
this.eventStore.append('STATE_TRANSITION', {
|
|
567
|
+
from: fromPath.join('.'),
|
|
568
|
+
to: toPath.join('.'),
|
|
569
|
+
signal: signalName,
|
|
570
|
+
payload,
|
|
571
|
+
}, {
|
|
572
|
+
state: toPath.join('.'),
|
|
573
|
+
causationId,
|
|
574
|
+
});
|
|
575
|
+
// 4. Update active state path and context updates
|
|
576
|
+
this.activeStatePath = [...toPath];
|
|
577
|
+
if (payload?.contextUpdates) {
|
|
578
|
+
this.updateContext(payload.contextUpdates);
|
|
579
|
+
}
|
|
580
|
+
// 5. Enter states from LCA (exclusive) down to leaf
|
|
581
|
+
this.enterPath(toPath, fromPath.slice(0, lcaDepth), signalName);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Enter a state hierarchy starting below the common ancestor
|
|
585
|
+
*/
|
|
586
|
+
enterPath(targetPath, ancestorPath, triggerSignal) {
|
|
587
|
+
for (let i = ancestorPath.length + 1; i <= targetPath.length; i++) {
|
|
588
|
+
const enterSubpath = targetPath.slice(0, i);
|
|
589
|
+
this.executeEntryHook(enterSubpath, triggerSignal);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
executeEntryHook(statePath, triggerSignal) {
|
|
593
|
+
const def = this.getStateDefinition(statePath);
|
|
594
|
+
if (!def)
|
|
595
|
+
return;
|
|
596
|
+
if (def.human_gate) {
|
|
597
|
+
this.eventStore.append('HUMAN_GATE_ENTERED', {
|
|
598
|
+
state: statePath.join('.'),
|
|
599
|
+
gateType: def.human_gate.type,
|
|
600
|
+
tool: def.human_gate.tool || 'ask_question',
|
|
601
|
+
options: def.human_gate.options,
|
|
602
|
+
}, { state: statePath.join('.') });
|
|
603
|
+
}
|
|
604
|
+
if (def.on_enter) {
|
|
605
|
+
for (const action of def.on_enter) {
|
|
606
|
+
if (action.set_context) {
|
|
607
|
+
this.updateContext(action.set_context);
|
|
608
|
+
}
|
|
609
|
+
if (action.emit_signal) {
|
|
610
|
+
this.eventStore.append('STATE_ENTRY_HOOK', {
|
|
611
|
+
state: statePath.join('.'),
|
|
612
|
+
signalEmitted: action.emit_signal,
|
|
613
|
+
action: action.action,
|
|
614
|
+
}, { state: statePath.join('.') });
|
|
615
|
+
// Queue signal to execute transition cleanly after current cycle
|
|
616
|
+
this.signalQueue.push({
|
|
617
|
+
signal: action.emit_signal,
|
|
618
|
+
payload: {},
|
|
619
|
+
metadata: { source: `on_enter:${statePath.join('.')}` },
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
executeExitHook(statePath, triggerSignal) {
|
|
626
|
+
const def = this.getStateDefinition(statePath);
|
|
627
|
+
if (!def || !def.on_exit)
|
|
628
|
+
return;
|
|
629
|
+
for (const action of def.on_exit) {
|
|
630
|
+
if (action.set_context) {
|
|
631
|
+
this.updateContext(action.set_context);
|
|
632
|
+
}
|
|
633
|
+
if (action.emit_signal) {
|
|
634
|
+
this.eventStore.append('STATE_EXIT_HOOK', {
|
|
635
|
+
state: statePath.join('.'),
|
|
636
|
+
signalEmitted: action.emit_signal,
|
|
637
|
+
action: action.action,
|
|
638
|
+
}, { state: statePath.join('.') });
|
|
639
|
+
// Queue signal
|
|
640
|
+
this.signalQueue.push({
|
|
641
|
+
signal: action.emit_signal,
|
|
642
|
+
payload: {},
|
|
643
|
+
metadata: { source: `on_exit:${statePath.join('.')}` },
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SignalEvent } from './types.js';
|
|
2
|
+
export interface GuardEvaluationContext {
|
|
3
|
+
event: SignalEvent;
|
|
4
|
+
context: Record<string, any>;
|
|
5
|
+
currentState: string;
|
|
6
|
+
skillDir?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Guard Evaluator: Safely checks transition guards and domain invariants inside an isolated sandbox
|
|
10
|
+
*/
|
|
11
|
+
export declare class GuardEvaluator {
|
|
12
|
+
/**
|
|
13
|
+
* Evaluate a transition guard expression or custom JS function file
|
|
14
|
+
*/
|
|
15
|
+
static evaluate(guardExpr: string | undefined, guardFunctionPath: string | undefined, evalContext: GuardEvaluationContext): Promise<{
|
|
16
|
+
passed: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|