@wopr-network/defcon 1.4.0 → 1.6.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.
@@ -12,6 +12,7 @@ export type ClaimResponse = {
12
12
  invocation_id: string;
13
13
  flow: string | null;
14
14
  stage: string;
15
+ agent_role: string | null;
15
16
  prompt: string;
16
17
  context: Record<string, unknown> | null;
17
18
  worker_notice?: string;
@@ -63,6 +63,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
63
63
  timeoutMs: g.timeoutMs,
64
64
  failurePrompt: g.failurePrompt,
65
65
  timeoutPrompt: g.timeoutPrompt,
66
+ outcomes: g.outcomes,
66
67
  });
67
68
  gateNameToId.set(g.name, gate.id);
68
69
  }
@@ -86,11 +87,13 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
86
87
  for (const s of flowStates) {
87
88
  await flowRepo.addState(flow.id, {
88
89
  name: s.name,
90
+ agentRole: s.agentRole,
89
91
  modelTier: s.modelTier,
90
92
  mode: s.mode,
91
93
  promptTemplate: s.promptTemplate,
92
94
  constraints: s.constraints,
93
95
  onEnter: s.onEnter,
96
+ onExit: s.onExit,
94
97
  retryAfterMs: s.retryAfterMs,
95
98
  });
96
99
  }
@@ -20,9 +20,14 @@ export declare const OnEnterSchema: z.ZodObject<{
20
20
  artifacts: z.ZodArray<z.ZodString>;
21
21
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
22
22
  }, z.core.$strip>;
23
+ export declare const OnExitSchema: z.ZodObject<{
24
+ command: z.ZodString;
25
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
26
+ }, z.core.$strip>;
23
27
  export declare const StateDefinitionSchema: z.ZodObject<{
24
28
  name: z.ZodString;
25
29
  flowName: z.ZodString;
30
+ agentRole: z.ZodOptional<z.ZodString>;
26
31
  modelTier: z.ZodOptional<z.ZodString>;
27
32
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
28
33
  passive: "passive";
@@ -35,6 +40,10 @@ export declare const StateDefinitionSchema: z.ZodObject<{
35
40
  artifacts: z.ZodArray<z.ZodString>;
36
41
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
37
42
  }, z.core.$strip>>;
43
+ onExit: z.ZodOptional<z.ZodObject<{
44
+ command: z.ZodString;
45
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
46
+ }, z.core.$strip>>;
38
47
  retryAfterMs: z.ZodOptional<z.ZodNumber>;
39
48
  }, z.core.$strip>;
40
49
  export declare const CommandGateSchema: z.ZodObject<{
@@ -42,6 +51,10 @@ export declare const CommandGateSchema: z.ZodObject<{
42
51
  timeoutMs: z.ZodOptional<z.ZodNumber>;
43
52
  failurePrompt: z.ZodOptional<z.ZodString>;
44
53
  timeoutPrompt: z.ZodOptional<z.ZodString>;
54
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
55
+ proceed: z.ZodOptional<z.ZodBoolean>;
56
+ toState: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strip>>>;
45
58
  type: z.ZodLiteral<"command">;
46
59
  command: z.ZodString;
47
60
  }, z.core.$strip>;
@@ -50,6 +63,10 @@ export declare const FunctionGateSchema: z.ZodObject<{
50
63
  timeoutMs: z.ZodOptional<z.ZodNumber>;
51
64
  failurePrompt: z.ZodOptional<z.ZodString>;
52
65
  timeoutPrompt: z.ZodOptional<z.ZodString>;
66
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
67
+ proceed: z.ZodOptional<z.ZodBoolean>;
68
+ toState: z.ZodOptional<z.ZodString>;
69
+ }, z.core.$strip>>>;
53
70
  type: z.ZodLiteral<"function">;
54
71
  functionRef: z.ZodString;
55
72
  }, z.core.$strip>;
@@ -58,6 +75,10 @@ export declare const ApiGateSchema: z.ZodObject<{
58
75
  timeoutMs: z.ZodOptional<z.ZodNumber>;
59
76
  failurePrompt: z.ZodOptional<z.ZodString>;
60
77
  timeoutPrompt: z.ZodOptional<z.ZodString>;
78
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
79
+ proceed: z.ZodOptional<z.ZodBoolean>;
80
+ toState: z.ZodOptional<z.ZodString>;
81
+ }, z.core.$strip>>>;
61
82
  type: z.ZodLiteral<"api">;
62
83
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
63
84
  }, z.core.$strip>;
@@ -66,6 +87,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
66
87
  timeoutMs: z.ZodOptional<z.ZodNumber>;
67
88
  failurePrompt: z.ZodOptional<z.ZodString>;
68
89
  timeoutPrompt: z.ZodOptional<z.ZodString>;
90
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
91
+ proceed: z.ZodOptional<z.ZodBoolean>;
92
+ toState: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strip>>>;
69
94
  type: z.ZodLiteral<"command">;
70
95
  command: z.ZodString;
71
96
  }, z.core.$strip>, z.ZodObject<{
@@ -73,6 +98,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
73
98
  timeoutMs: z.ZodOptional<z.ZodNumber>;
74
99
  failurePrompt: z.ZodOptional<z.ZodString>;
75
100
  timeoutPrompt: z.ZodOptional<z.ZodString>;
101
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
102
+ proceed: z.ZodOptional<z.ZodBoolean>;
103
+ toState: z.ZodOptional<z.ZodString>;
104
+ }, z.core.$strip>>>;
76
105
  type: z.ZodLiteral<"function">;
77
106
  functionRef: z.ZodString;
78
107
  }, z.core.$strip>, z.ZodObject<{
@@ -80,6 +109,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
80
109
  timeoutMs: z.ZodOptional<z.ZodNumber>;
81
110
  failurePrompt: z.ZodOptional<z.ZodString>;
82
111
  timeoutPrompt: z.ZodOptional<z.ZodString>;
112
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
113
+ proceed: z.ZodOptional<z.ZodBoolean>;
114
+ toState: z.ZodOptional<z.ZodString>;
115
+ }, z.core.$strip>>>;
83
116
  type: z.ZodLiteral<"api">;
84
117
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
85
118
  }, z.core.$strip>], "type">;
@@ -114,6 +147,7 @@ export declare const SeedFileSchema: z.ZodObject<{
114
147
  states: z.ZodArray<z.ZodObject<{
115
148
  name: z.ZodString;
116
149
  flowName: z.ZodString;
150
+ agentRole: z.ZodOptional<z.ZodString>;
117
151
  modelTier: z.ZodOptional<z.ZodString>;
118
152
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
119
153
  passive: "passive";
@@ -126,6 +160,10 @@ export declare const SeedFileSchema: z.ZodObject<{
126
160
  artifacts: z.ZodArray<z.ZodString>;
127
161
  timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
128
162
  }, z.core.$strip>>;
163
+ onExit: z.ZodOptional<z.ZodObject<{
164
+ command: z.ZodString;
165
+ timeout_ms: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
166
+ }, z.core.$strip>>;
129
167
  retryAfterMs: z.ZodOptional<z.ZodNumber>;
130
168
  }, z.core.$strip>>;
131
169
  gates: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -133,6 +171,10 @@ export declare const SeedFileSchema: z.ZodObject<{
133
171
  timeoutMs: z.ZodOptional<z.ZodNumber>;
134
172
  failurePrompt: z.ZodOptional<z.ZodString>;
135
173
  timeoutPrompt: z.ZodOptional<z.ZodString>;
174
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
175
+ proceed: z.ZodOptional<z.ZodBoolean>;
176
+ toState: z.ZodOptional<z.ZodString>;
177
+ }, z.core.$strip>>>;
136
178
  type: z.ZodLiteral<"command">;
137
179
  command: z.ZodString;
138
180
  }, z.core.$strip>, z.ZodObject<{
@@ -140,6 +182,10 @@ export declare const SeedFileSchema: z.ZodObject<{
140
182
  timeoutMs: z.ZodOptional<z.ZodNumber>;
141
183
  failurePrompt: z.ZodOptional<z.ZodString>;
142
184
  timeoutPrompt: z.ZodOptional<z.ZodString>;
185
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
186
+ proceed: z.ZodOptional<z.ZodBoolean>;
187
+ toState: z.ZodOptional<z.ZodString>;
188
+ }, z.core.$strip>>>;
143
189
  type: z.ZodLiteral<"function">;
144
190
  functionRef: z.ZodString;
145
191
  }, z.core.$strip>, z.ZodObject<{
@@ -147,6 +193,10 @@ export declare const SeedFileSchema: z.ZodObject<{
147
193
  timeoutMs: z.ZodOptional<z.ZodNumber>;
148
194
  failurePrompt: z.ZodOptional<z.ZodString>;
149
195
  timeoutPrompt: z.ZodOptional<z.ZodString>;
196
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
197
+ proceed: z.ZodOptional<z.ZodBoolean>;
198
+ toState: z.ZodOptional<z.ZodString>;
199
+ }, z.core.$strip>>>;
150
200
  type: z.ZodLiteral<"api">;
151
201
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
152
202
  }, z.core.$strip>], "type">>>>;
@@ -34,9 +34,19 @@ export const OnEnterSchema = z.object({
34
34
  artifacts: z.array(z.string().min(1)).min(1),
35
35
  timeout_ms: z.number().int().min(0).optional().default(30000),
36
36
  });
37
+ export const OnExitSchema = z.object({
38
+ command: z
39
+ .string()
40
+ .min(1)
41
+ .refine((val) => validateTemplate(val), {
42
+ message: "onExit command contains disallowed Handlebars expressions",
43
+ }),
44
+ timeout_ms: z.number().int().min(1).optional().default(30000),
45
+ });
37
46
  export const StateDefinitionSchema = z.object({
38
47
  name: z.string().min(1),
39
48
  flowName: z.string().min(1),
49
+ agentRole: z.string().optional(),
40
50
  modelTier: z.string().optional(),
41
51
  mode: z.enum(["passive", "active"]).optional().default("passive"),
42
52
  promptTemplate: z
@@ -48,14 +58,20 @@ export const StateDefinitionSchema = z.object({
48
58
  .optional(),
49
59
  constraints: z.record(z.string(), z.unknown()).optional(),
50
60
  onEnter: OnEnterSchema.optional(),
61
+ onExit: OnExitSchema.optional(),
51
62
  retryAfterMs: z.number().int().min(0).optional(),
52
63
  });
53
64
  // Gate: discriminated union on `type`
65
+ const GateOutcomeSchema = z.object({
66
+ proceed: z.boolean().optional(),
67
+ toState: z.string().min(1).optional(),
68
+ });
54
69
  const BaseGateSchema = z.object({
55
70
  name: z.string().min(1),
56
71
  timeoutMs: z.number().int().min(1).optional(),
57
72
  failurePrompt: z.string().optional(),
58
73
  timeoutPrompt: z.string().min(1).optional(),
74
+ outcomes: z.record(z.string(), GateOutcomeSchema).optional(),
59
75
  });
60
76
  export const CommandGateSchema = BaseGateSchema.extend({
61
77
  type: z.literal("command"),
@@ -56,6 +56,13 @@ export declare class Engine {
56
56
  listDrainingWorkers(): string[];
57
57
  emit(event: import("./event-types.js").EngineEvent): Promise<void>;
58
58
  processSignal(entityId: string, signal: string, artifacts?: Artifacts, triggeringInvocationId?: string): Promise<ProcessSignalResult>;
59
+ /**
60
+ * Evaluate a gate and return a routing decision:
61
+ * - `proceed` — gate passed, continue to transition.toState
62
+ * - `redirect` — gate outcome maps to a different toState
63
+ * - `block` — gate failed or timed out; caller should return this as the signal result
64
+ */
65
+ private resolveGate;
59
66
  createEntity(flowName: string, refs?: Record<string, {
60
67
  adapter: string;
61
68
  id: string;
@@ -6,6 +6,7 @@ import { evaluateGate } from "./gate-evaluator.js";
6
6
  import { getHandlebars } from "./handlebars.js";
7
7
  import { buildInvocation } from "./invocation-builder.js";
8
8
  import { executeOnEnter } from "./on-enter.js";
9
+ import { executeOnExit } from "./on-exit.js";
9
10
  import { findTransition, isTerminal } from "./state-machine.js";
10
11
  export class Engine {
11
12
  entityRepo;
@@ -55,83 +56,43 @@ export class Engine {
55
56
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
56
57
  if (!transition)
57
58
  throw new ValidationError(`No transition from "${entity.state}" on signal "${signal}" in flow "${flow.name}"`);
58
- // 4. Evaluate gate if present
59
- const gatesPassed = [];
60
- if (transition.gateId) {
61
- const gate = await this.gateRepo.get(transition.gateId);
62
- if (!gate)
63
- throw new NotFoundError(`Gate "${transition.gateId}" not found`);
64
- const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
65
- if (!gateResult.passed) {
66
- // Persist gate failure into entity artifacts for retry context
67
- const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
68
- ? entity.artifacts.gate_failures
69
- : [];
70
- await this.entityRepo.updateArtifacts(entityId, {
71
- gate_failures: [
72
- ...priorFailures,
73
- {
74
- gateId: gate.id,
75
- gateName: gate.name,
76
- output: gateResult.output,
77
- failedAt: new Date().toISOString(),
78
- },
79
- ],
59
+ // 4. Evaluate gate returns a routing decision or a block result to return immediately.
60
+ const routing = transition.gateId
61
+ ? await this.resolveGate(transition.gateId, entity, flow)
62
+ : { kind: "proceed", gatesPassed: [] };
63
+ if (routing.kind === "block") {
64
+ return { gated: true, ...routing, terminal: false };
65
+ }
66
+ // Gate passed or redirected — determine the actual destination.
67
+ const toState = routing.kind === "redirect" ? routing.toState : transition.toState;
68
+ const trigger = routing.kind === "redirect" ? routing.trigger : signal;
69
+ const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
70
+ const { gatesPassed } = routing;
71
+ // 4b. Execute onExit hook on the DEPARTING state (before transition)
72
+ const departingStateDef = flow.states.find((s) => s.name === entity.state);
73
+ if (departingStateDef?.onExit) {
74
+ const onExitResult = await executeOnExit(departingStateDef.onExit, entity);
75
+ if (onExitResult.error) {
76
+ this.logger.warn(`[engine] onExit failed for entity ${entityId} state ${entity.state}: ${onExitResult.error}`);
77
+ await this.eventEmitter.emit({
78
+ type: "onExit.failed",
79
+ entityId,
80
+ state: entity.state,
81
+ error: onExitResult.error,
82
+ emittedAt: new Date(),
83
+ });
84
+ }
85
+ else {
86
+ await this.eventEmitter.emit({
87
+ type: "onExit.completed",
88
+ entityId,
89
+ state: entity.state,
90
+ emittedAt: new Date(),
80
91
  });
81
- if (gateResult.timedOut) {
82
- await this.eventEmitter.emit({
83
- type: "gate.timedOut",
84
- entityId,
85
- gateId: gate.id,
86
- emittedAt: new Date(),
87
- });
88
- }
89
- else {
90
- await this.eventEmitter.emit({
91
- type: "gate.failed",
92
- entityId,
93
- gateId: gate.id,
94
- emittedAt: new Date(),
95
- });
96
- }
97
- let resolvedTimeoutPrompt;
98
- if (gateResult.timedOut) {
99
- const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
100
- try {
101
- const hbs = getHandlebars();
102
- const template = hbs.compile(rawTemplate);
103
- resolvedTimeoutPrompt = template({
104
- entity,
105
- flow,
106
- gate: { name: gate.name, output: gateResult.output },
107
- });
108
- }
109
- catch (err) {
110
- this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
111
- resolvedTimeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
112
- }
113
- }
114
- return {
115
- gated: true,
116
- gateTimedOut: gateResult.timedOut,
117
- gateOutput: gateResult.output,
118
- gateName: gate.name,
119
- failurePrompt: gate.failurePrompt ?? undefined,
120
- timeoutPrompt: resolvedTimeoutPrompt,
121
- gatesPassed,
122
- terminal: false,
123
- };
124
92
  }
125
- gatesPassed.push(gate.name);
126
- await this.eventEmitter.emit({
127
- type: "gate.passed",
128
- entityId,
129
- gateId: gate.id,
130
- emittedAt: new Date(),
131
- });
132
93
  }
133
94
  // 5. Transition entity
134
- let updated = await this.entityRepo.transition(entityId, transition.toState, signal, artifacts);
95
+ let updated = await this.entityRepo.transition(entityId, toState, trigger, artifacts);
135
96
  // Clear gate_failures on successful transition so stale failures don't bleed into future agent prompts
136
97
  await this.entityRepo.updateArtifacts(entityId, { gate_failures: [] });
137
98
  // Keep the in-memory entity in sync so buildInvocation sees the cleared failures
@@ -142,25 +103,25 @@ export class Engine {
142
103
  entityId,
143
104
  flowId: flow.id,
144
105
  fromState: entity.state,
145
- toState: transition.toState,
146
- trigger: signal,
106
+ toState,
107
+ trigger,
147
108
  emittedAt: new Date(),
148
109
  });
149
110
  const result = {
150
- newState: transition.toState,
111
+ newState: toState,
151
112
  gatesPassed,
152
113
  gated: false,
153
114
  terminal: false,
154
115
  };
155
116
  // 6b. Execute onEnter hook if defined on the new state
156
- const newStateDef = flow.states.find((s) => s.name === transition.toState);
117
+ const newStateDef = flow.states.find((s) => s.name === toState);
157
118
  if (newStateDef?.onEnter) {
158
119
  const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
159
120
  if (onEnterResult.skipped) {
160
121
  await this.eventEmitter.emit({
161
122
  type: "onEnter.skipped",
162
123
  entityId,
163
- state: transition.toState,
124
+ state: toState,
164
125
  emittedAt: new Date(),
165
126
  });
166
127
  }
@@ -168,20 +129,20 @@ export class Engine {
168
129
  await this.eventEmitter.emit({
169
130
  type: "onEnter.failed",
170
131
  entityId,
171
- state: transition.toState,
132
+ state: toState,
172
133
  error: onEnterResult.error,
173
134
  emittedAt: new Date(),
174
135
  });
175
136
  await this.transitionLogRepo.record({
176
137
  entityId,
177
138
  fromState: entity.state,
178
- toState: transition.toState,
179
- trigger: signal,
139
+ toState,
140
+ trigger,
180
141
  invocationId: triggeringInvocationId ?? null,
181
142
  timestamp: new Date(),
182
143
  });
183
144
  return {
184
- newState: transition.toState,
145
+ newState: toState,
185
146
  gatesPassed,
186
147
  gated: false,
187
148
  onEnterFailed: true,
@@ -193,7 +154,7 @@ export class Engine {
193
154
  await this.eventEmitter.emit({
194
155
  type: "onEnter.completed",
195
156
  entityId,
196
- state: transition.toState,
157
+ state: toState,
197
158
  artifacts: onEnterResult.artifacts ?? {},
198
159
  emittedAt: new Date(),
199
160
  });
@@ -214,15 +175,15 @@ export class Engine {
214
175
  ]);
215
176
  const enriched = { ...updated, invocations, gateResults };
216
177
  const build = await buildInvocation(newStateDef, enriched, this.adapters, flow, this.logger);
217
- const invocation = await this.invocationRepo.create(entityId, transition.toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
178
+ const invocation = await this.invocationRepo.create(entityId, toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
218
179
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
219
- : undefined);
180
+ : undefined, newStateDef.agentRole ?? null);
220
181
  result.invocationId = invocation.id;
221
182
  await this.eventEmitter.emit({
222
183
  type: "invocation.created",
223
184
  entityId,
224
185
  invocationId: invocation.id,
225
- stage: transition.toState,
186
+ stage: toState,
226
187
  emittedAt: new Date(),
227
188
  });
228
189
  }
@@ -232,13 +193,13 @@ export class Engine {
232
193
  await this.transitionLogRepo.record({
233
194
  entityId,
234
195
  fromState: entity.state,
235
- toState: transition.toState,
236
- trigger: signal,
196
+ toState,
197
+ trigger,
237
198
  invocationId: triggeringInvocationId ?? null,
238
199
  timestamp: new Date(),
239
200
  });
240
201
  // 9. Spawn child flows
241
- const spawned = await executeSpawn(transition, updated, this.flowRepo, this.entityRepo, this.logger);
202
+ const spawned = await executeSpawn({ spawnFlow }, updated, this.flowRepo, this.entityRepo, this.logger);
242
203
  if (spawned) {
243
204
  result.spawned = [spawned.id];
244
205
  await this.eventEmitter.emit({
@@ -249,14 +210,93 @@ export class Engine {
249
210
  emittedAt: new Date(),
250
211
  });
251
212
  }
252
- // 10. Mark terminal — no invocation is created for terminal states (handled above),
253
- // but we surface terminality in the result for callers.
254
- if (isTerminal(flow, transition.toState)) {
213
+ // 10. Mark terminal
214
+ if (isTerminal(flow, toState)) {
255
215
  result.terminal = true;
256
216
  result.spawned = result.spawned ?? [];
257
217
  }
258
218
  return result;
259
219
  }
220
+ /**
221
+ * Evaluate a gate and return a routing decision:
222
+ * - `proceed` — gate passed, continue to transition.toState
223
+ * - `redirect` — gate outcome maps to a different toState
224
+ * - `block` — gate failed or timed out; caller should return this as the signal result
225
+ */
226
+ async resolveGate(gateId, entity, flow) {
227
+ const gate = await this.gateRepo.get(gateId);
228
+ if (!gate)
229
+ throw new NotFoundError(`Gate "${gateId}" not found`);
230
+ const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
231
+ const namedOutcome = gateResult.outcome && gate.outcomes ? gate.outcomes[gateResult.outcome] : undefined;
232
+ if (namedOutcome?.toState) {
233
+ const outcomeLabel = gateResult.outcome ?? gate.name;
234
+ await this.eventEmitter.emit({
235
+ type: "gate.redirected",
236
+ entityId: entity.id,
237
+ gateId: gate.id,
238
+ outcome: outcomeLabel,
239
+ toState: namedOutcome.toState,
240
+ emittedAt: new Date(),
241
+ });
242
+ return {
243
+ kind: "redirect",
244
+ toState: namedOutcome.toState,
245
+ trigger: `gate:${gate.name}:${outcomeLabel}`,
246
+ gatesPassed: [gate.name],
247
+ };
248
+ }
249
+ if (namedOutcome?.proceed || (!namedOutcome && gateResult.passed)) {
250
+ await this.eventEmitter.emit({
251
+ type: "gate.passed",
252
+ entityId: entity.id,
253
+ gateId: gate.id,
254
+ emittedAt: new Date(),
255
+ });
256
+ return { kind: "proceed", gatesPassed: [gate.name] };
257
+ }
258
+ // Gate failed — persist failure context and emit event
259
+ const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
260
+ ? entity.artifacts.gate_failures
261
+ : [];
262
+ await this.entityRepo.updateArtifacts(entity.id, {
263
+ gate_failures: [
264
+ ...priorFailures,
265
+ { gateId: gate.id, gateName: gate.name, output: gateResult.output, failedAt: new Date().toISOString() },
266
+ ],
267
+ });
268
+ await this.eventEmitter.emit({
269
+ type: gateResult.timedOut ? "gate.timedOut" : "gate.failed",
270
+ entityId: entity.id,
271
+ gateId: gate.id,
272
+ emittedAt: new Date(),
273
+ });
274
+ let timeoutPrompt;
275
+ if (gateResult.timedOut) {
276
+ const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
277
+ try {
278
+ const hbs = getHandlebars();
279
+ timeoutPrompt = hbs.compile(rawTemplate)({
280
+ entity,
281
+ flow,
282
+ gate: { name: gate.name, output: gateResult.output },
283
+ });
284
+ }
285
+ catch (err) {
286
+ this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
287
+ timeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
288
+ }
289
+ }
290
+ return {
291
+ kind: "block",
292
+ gateTimedOut: gateResult.timedOut,
293
+ gateOutput: gateResult.output,
294
+ gateName: gate.name,
295
+ failurePrompt: gate.failurePrompt ?? undefined,
296
+ timeoutPrompt,
297
+ gatesPassed: [],
298
+ };
299
+ }
260
300
  async createEntity(flowName, refs, payload) {
261
301
  const flow = await this.flowRepo.getByName(flowName);
262
302
  if (!flow)
@@ -315,7 +355,7 @@ export class Engine {
315
355
  const build = await buildInvocation(initialState, enriched, this.adapters, flow, this.logger);
316
356
  await this.invocationRepo.create(entity.id, flow.initialState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
317
357
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
318
- : undefined);
358
+ : undefined, initialState.agentRole ?? null);
319
359
  }
320
360
  return entity;
321
361
  }
@@ -487,7 +527,7 @@ export class Engine {
487
527
  const build = await this.buildPromptForEntity(state, claimed, flow);
488
528
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
489
529
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
490
- : undefined);
530
+ : undefined, state.agentRole ?? null);
491
531
  const entityClaimToken = worker_id ?? `agent:${role}`;
492
532
  let claimedInvocation;
493
533
  try {
@@ -68,6 +68,13 @@ export type EngineEvent = {
68
68
  entityId: string;
69
69
  gateId: string;
70
70
  emittedAt: Date;
71
+ } | {
72
+ type: "gate.redirected";
73
+ entityId: string;
74
+ gateId: string;
75
+ outcome: string;
76
+ toState: string;
77
+ emittedAt: Date;
71
78
  } | {
72
79
  type: "flow.spawned";
73
80
  entityId: string;
@@ -97,6 +104,17 @@ export type EngineEvent = {
97
104
  entityId: string;
98
105
  state: string;
99
106
  emittedAt: Date;
107
+ } | {
108
+ type: "onExit.completed";
109
+ entityId: string;
110
+ state: string;
111
+ emittedAt: Date;
112
+ } | {
113
+ type: "onExit.failed";
114
+ entityId: string;
115
+ state: string;
116
+ error: string;
117
+ emittedAt: Date;
100
118
  };
101
119
  /** Adapter for broadcasting engine events to external systems. */
102
120
  export interface IEventBusAdapter {
@@ -1,8 +1,10 @@
1
1
  import type { Logger } from "../logger.js";
2
- import type { Entity, IEntityRepository, IFlowRepository, Transition } from "../repositories/interfaces.js";
2
+ import type { Entity, IEntityRepository, IFlowRepository } from "../repositories/interfaces.js";
3
3
  /**
4
4
  * If the transition has a spawnFlow, look up that flow and create a new entity in it.
5
5
  * The spawned entity inherits the parent entity's refs.
6
6
  * Returns the spawned entity, or null if no spawn is configured.
7
7
  */
8
- export declare function executeSpawn(transition: Transition, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
8
+ export declare function executeSpawn(transition: {
9
+ spawnFlow: string | null | undefined;
10
+ }, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
@@ -3,6 +3,10 @@ export interface GateEvalResult {
3
3
  passed: boolean;
4
4
  timedOut: boolean;
5
5
  output: string;
6
+ /** Named outcome from structured JSON output, if the gate emitted one. */
7
+ outcome?: string;
8
+ /** Human-readable message from structured JSON output. */
9
+ message?: string;
6
10
  }
7
11
  export declare function resolveGateTimeout(gateTimeoutMs: number | null | undefined, flowGateTimeoutMs: number | null | undefined): number;
8
12
  /**
@@ -59,6 +59,33 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
59
59
  passed = result.exitCode === 0;
60
60
  output = result.output;
61
61
  timedOut = result.timedOut;
62
+ // Parse structured JSON outcome from the last non-empty output line.
63
+ // Gate scripts can emit { outcome: string, message?: string } as their final line
64
+ // to enable named outcome routing (e.g. "blocked" → toState: "fixing").
65
+ const lastLine = result.output
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter(Boolean)
69
+ .at(-1);
70
+ if (lastLine?.startsWith("{")) {
71
+ try {
72
+ const parsed = JSON.parse(lastLine);
73
+ if (typeof parsed.outcome === "string") {
74
+ const outcomeResult = {
75
+ passed,
76
+ timedOut,
77
+ output: result.output,
78
+ outcome: parsed.outcome,
79
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
80
+ };
81
+ await gateRepo.record(entity.id, gate.id, passed, result.output);
82
+ return outcomeResult;
83
+ }
84
+ }
85
+ catch {
86
+ // Not JSON — treat output as plain text, fall through to normal path
87
+ }
88
+ }
62
89
  }
63
90
  else if (gate.type === "function") {
64
91
  try {
@@ -0,0 +1,6 @@
1
+ import type { Entity, OnExitConfig } from "../repositories/interfaces.js";
2
+ export interface OnExitResult {
3
+ error: string | null;
4
+ timedOut: boolean;
5
+ }
6
+ export declare function executeOnExit(onExit: OnExitConfig, entity: Entity): Promise<OnExitResult>;