agentxchain 2.46.0 → 2.47.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.
@@ -0,0 +1,90 @@
1
+ const VALID_TRIGGERS = new Set([
2
+ 'manual',
3
+ 'continuation',
4
+ 'recovery',
5
+ 'intake',
6
+ 'schedule',
7
+ 'coordinator',
8
+ ]);
9
+
10
+ const VALID_CREATORS = new Set([
11
+ 'operator',
12
+ 'coordinator',
13
+ ]);
14
+
15
+ function normalizeString(value) {
16
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
17
+ }
18
+
19
+ export function normalizeRunProvenance(value, { fallbackManual = false } = {}) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
21
+ return fallbackManual ? buildDefaultRunProvenance() : null;
22
+ }
23
+
24
+ const trigger = normalizeString(value.trigger);
25
+ const createdBy = normalizeString(value.created_by);
26
+ const normalized = {
27
+ trigger: VALID_TRIGGERS.has(trigger) ? trigger : (fallbackManual ? 'manual' : null),
28
+ parent_run_id: normalizeString(value.parent_run_id),
29
+ trigger_reason: normalizeString(value.trigger_reason),
30
+ intake_intent_id: normalizeString(value.intake_intent_id),
31
+ created_by: VALID_CREATORS.has(createdBy) ? createdBy : 'operator',
32
+ };
33
+
34
+ if (!normalized.trigger) {
35
+ return fallbackManual ? buildDefaultRunProvenance() : null;
36
+ }
37
+
38
+ return normalized;
39
+ }
40
+
41
+ export function buildDefaultRunProvenance(overrides = {}) {
42
+ return normalizeRunProvenance({
43
+ trigger: 'manual',
44
+ parent_run_id: null,
45
+ trigger_reason: null,
46
+ intake_intent_id: null,
47
+ created_by: 'operator',
48
+ ...overrides,
49
+ }, { fallbackManual: true });
50
+ }
51
+
52
+ export function getRunTriggerLabel(provenance) {
53
+ const normalized = normalizeRunProvenance(provenance);
54
+ return normalized?.trigger || 'legacy';
55
+ }
56
+
57
+ export function summarizeRunProvenance(provenance) {
58
+ const normalized = normalizeRunProvenance(provenance);
59
+ if (!normalized) return null;
60
+
61
+ const details = [];
62
+ if (normalized.parent_run_id) {
63
+ details.push(`from ${normalized.parent_run_id}`);
64
+ }
65
+ if (normalized.intake_intent_id) {
66
+ details.push(`intent ${normalized.intake_intent_id}`);
67
+ }
68
+
69
+ const base = details.length > 0
70
+ ? `${normalized.trigger} ${details.join(' ')}`
71
+ : normalized.trigger;
72
+ const creatorSuffix = normalized.created_by === 'coordinator'
73
+ ? ' (created by coordinator)'
74
+ : '';
75
+ const reasonSuffix = normalized.trigger_reason
76
+ ? ` ("${normalized.trigger_reason}")`
77
+ : '';
78
+
79
+ if (
80
+ normalized.trigger === 'manual'
81
+ && !normalized.parent_run_id
82
+ && !normalized.intake_intent_id
83
+ && !normalized.trigger_reason
84
+ && normalized.created_by !== 'coordinator'
85
+ ) {
86
+ return null;
87
+ }
88
+
89
+ return `${base}${creatorSuffix}${reasonSuffix}`;
90
+ }
package/src/lib/schema.js CHANGED
@@ -74,6 +74,12 @@ export function validateGovernedStateSchema(data) {
74
74
  errors.push(`status must be one of: ${VALID_RUN_STATUSES.join(', ')}`);
75
75
  }
76
76
  if (typeof data.phase !== 'string' || !data.phase.trim()) errors.push('phase must be a non-empty string');
77
+ if ('created_at' in data && data.created_at !== null && (typeof data.created_at !== 'string' || !data.created_at.trim())) {
78
+ errors.push('created_at must be a non-empty string or null');
79
+ }
80
+ if ('phase_entered_at' in data && data.phase_entered_at !== null && (typeof data.phase_entered_at !== 'string' || !data.phase_entered_at.trim())) {
81
+ errors.push('phase_entered_at must be a non-empty string or null');
82
+ }
77
83
 
78
84
  if (isV1_1) {
79
85
  if (hasLegacyCurrentTurn) {
@@ -96,6 +102,47 @@ export function validateGovernedStateSchema(data) {
96
102
  if ('phase_gate_status' in data && data.phase_gate_status !== null && typeof data.phase_gate_status !== 'object') {
97
103
  errors.push('phase_gate_status must be an object');
98
104
  }
105
+ if ('last_gate_failure' in data && data.last_gate_failure !== null) {
106
+ if (typeof data.last_gate_failure !== 'object' || Array.isArray(data.last_gate_failure)) {
107
+ errors.push('last_gate_failure must be an object or null');
108
+ } else {
109
+ const failure = data.last_gate_failure;
110
+ const validGateTypes = ['phase_transition', 'run_completion'];
111
+ if (typeof failure.gate_type !== 'string' || !validGateTypes.includes(failure.gate_type)) {
112
+ errors.push(`last_gate_failure.gate_type must be one of: ${validGateTypes.join(', ')}`);
113
+ }
114
+ if (failure.gate_id !== null && failure.gate_id !== undefined && typeof failure.gate_id !== 'string') {
115
+ errors.push('last_gate_failure.gate_id must be a string or null');
116
+ }
117
+ if (typeof failure.phase !== 'string' || !failure.phase.trim()) {
118
+ errors.push('last_gate_failure.phase must be a non-empty string');
119
+ }
120
+ if (failure.from_phase !== null && failure.from_phase !== undefined && typeof failure.from_phase !== 'string') {
121
+ errors.push('last_gate_failure.from_phase must be a string or null');
122
+ }
123
+ if (failure.to_phase !== null && failure.to_phase !== undefined && typeof failure.to_phase !== 'string') {
124
+ errors.push('last_gate_failure.to_phase must be a string or null');
125
+ }
126
+ if (failure.requested_by_turn !== null && failure.requested_by_turn !== undefined && typeof failure.requested_by_turn !== 'string') {
127
+ errors.push('last_gate_failure.requested_by_turn must be a string or null');
128
+ }
129
+ if (typeof failure.failed_at !== 'string' || !failure.failed_at.trim()) {
130
+ errors.push('last_gate_failure.failed_at must be a non-empty string');
131
+ }
132
+ if (typeof failure.queued_request !== 'boolean') {
133
+ errors.push('last_gate_failure.queued_request must be a boolean');
134
+ }
135
+ if (!Array.isArray(failure.reasons) || failure.reasons.some((reason) => typeof reason !== 'string')) {
136
+ errors.push('last_gate_failure.reasons must be an array of strings');
137
+ }
138
+ if (!Array.isArray(failure.missing_files) || failure.missing_files.some((file) => typeof file !== 'string')) {
139
+ errors.push('last_gate_failure.missing_files must be an array of strings');
140
+ }
141
+ if (typeof failure.missing_verification !== 'boolean') {
142
+ errors.push('last_gate_failure.missing_verification must be a boolean');
143
+ }
144
+ }
145
+ }
99
146
  if ('budget_status' in data && data.budget_status !== null && typeof data.budget_status !== 'object') {
100
147
  errors.push('budget_status must be an object');
101
148
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Timeout evaluator for governed runs.
3
+ *
4
+ * Checks per-turn, per-phase, and per-run time limits at governance boundaries.
5
+ * Returns timeout results that callers use to escalate, warn, or skip.
6
+ */
7
+
8
+ const VALID_TIMEOUT_ACTIONS = ['escalate', 'warn', 'skip_phase'];
9
+
10
+ /**
11
+ * Evaluate all configured timeouts against the current state.
12
+ *
13
+ * @param {object} options
14
+ * @param {object} options.config - Normalized config with optional `timeouts` section
15
+ * @param {object} options.state - Current governed state
16
+ * @param {object} [options.turn] - Active turn metadata being accepted (for per-turn check)
17
+ * @param {object} [options.turnResult] - Accepted turn result (legacy fallback for tests)
18
+ * @param {Date|string} [options.now] - Override for current time (testing)
19
+ * @returns {{ exceeded: Array<TimeoutResult>, warnings: Array<TimeoutResult> }}
20
+ */
21
+ export function evaluateTimeouts({ config, state, turn = null, turnResult = null, now = new Date() }) {
22
+ const timeouts = config.timeouts;
23
+ if (!timeouts) return { exceeded: [], warnings: [] };
24
+
25
+ const nowMs = typeof now === 'string' ? new Date(now).getTime() : now.getTime();
26
+ const exceeded = [];
27
+ const warnings = [];
28
+
29
+ // Per-turn timeout
30
+ if (timeouts.per_turn_minutes) {
31
+ const startedAt = turn?.started_at || turn?.assigned_at || turnResult?.dispatched_at || turnResult?.assigned_at;
32
+ if (startedAt) {
33
+ const dispatchMs = new Date(startedAt).getTime();
34
+ const limitMs = timeouts.per_turn_minutes * 60 * 1000;
35
+ const elapsedMs = nowMs - dispatchMs;
36
+ if (elapsedMs > limitMs) {
37
+ const result = {
38
+ scope: 'turn',
39
+ limit_minutes: timeouts.per_turn_minutes,
40
+ elapsed_minutes: Math.round(elapsedMs / 60000),
41
+ exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
42
+ action: resolveAction(timeouts.action, 'turn'),
43
+ };
44
+ if (result.action === 'warn') {
45
+ warnings.push(result);
46
+ } else {
47
+ exceeded.push(result);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ // Per-phase timeout
54
+ const phaseLimit = resolvePhaseLimit(timeouts, config.routing, state.phase);
55
+ const phaseAction = resolvePhaseAction(timeouts, config.routing, state.phase);
56
+ if (phaseLimit) {
57
+ const phaseEnteredAt = findPhaseEntryTime(state);
58
+ if (phaseEnteredAt) {
59
+ const entryMs = new Date(phaseEnteredAt).getTime();
60
+ const limitMs = phaseLimit * 60 * 1000;
61
+ const elapsedMs = nowMs - entryMs;
62
+ if (elapsedMs > limitMs) {
63
+ const result = {
64
+ scope: 'phase',
65
+ phase: state.phase,
66
+ limit_minutes: phaseLimit,
67
+ elapsed_minutes: Math.round(elapsedMs / 60000),
68
+ exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
69
+ action: phaseAction,
70
+ };
71
+ if (result.action === 'warn') {
72
+ warnings.push(result);
73
+ } else {
74
+ exceeded.push(result);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ // Per-run timeout
81
+ if (timeouts.per_run_minutes) {
82
+ const createdAt = state.created_at;
83
+ if (createdAt) {
84
+ const createMs = new Date(createdAt).getTime();
85
+ const limitMs = timeouts.per_run_minutes * 60 * 1000;
86
+ const elapsedMs = nowMs - createMs;
87
+ if (elapsedMs > limitMs) {
88
+ const result = {
89
+ scope: 'run',
90
+ limit_minutes: timeouts.per_run_minutes,
91
+ elapsed_minutes: Math.round(elapsedMs / 60000),
92
+ exceeded_by_minutes: Math.round((elapsedMs - limitMs) / 60000),
93
+ action: resolveAction(timeouts.action, 'run'),
94
+ };
95
+ if (result.action === 'warn') {
96
+ warnings.push(result);
97
+ } else {
98
+ exceeded.push(result);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return { exceeded, warnings };
105
+ }
106
+
107
+ /**
108
+ * Resolve the phase-level timeout limit.
109
+ * Per-phase routing override takes precedence over global per_phase_minutes.
110
+ */
111
+ function resolvePhaseLimit(timeouts, routing, phase) {
112
+ if (routing && routing[phase] && typeof routing[phase].timeout_minutes === 'number') {
113
+ return routing[phase].timeout_minutes;
114
+ }
115
+ return timeouts.per_phase_minutes || null;
116
+ }
117
+
118
+ /**
119
+ * Resolve the phase-level timeout action.
120
+ * Per-phase routing override takes precedence over global action.
121
+ */
122
+ function resolvePhaseAction(timeouts, routing, phase) {
123
+ if (routing && routing[phase] && routing[phase].timeout_action) {
124
+ return routing[phase].timeout_action;
125
+ }
126
+ return timeouts.action || 'escalate';
127
+ }
128
+
129
+ /**
130
+ * Resolve the effective action, enforcing that skip_phase is only valid for phase scope.
131
+ */
132
+ function resolveAction(action, scope) {
133
+ if (action === 'skip_phase' && scope !== 'phase') {
134
+ return 'escalate';
135
+ }
136
+ return action || 'escalate';
137
+ }
138
+
139
+ /**
140
+ * Find when the current phase was entered.
141
+ * Uses phase_entered_at if available, otherwise falls back to created_at for the first phase.
142
+ */
143
+ function findPhaseEntryTime(state) {
144
+ if (state.phase_entered_at) return state.phase_entered_at;
145
+ // Fallback for first phase: use run creation time
146
+ return state.created_at || null;
147
+ }
148
+
149
+ /**
150
+ * Validate the timeouts config section.
151
+ * Returns { ok, errors }.
152
+ */
153
+ export function validateTimeoutsConfig(timeouts, routing) {
154
+ const errors = [];
155
+
156
+ if (timeouts === null || timeouts === undefined) {
157
+ return { ok: true, errors: [] };
158
+ }
159
+
160
+ if (typeof timeouts !== 'object' || Array.isArray(timeouts)) {
161
+ errors.push('timeouts must be an object');
162
+ return { ok: false, errors };
163
+ }
164
+
165
+ if ('per_turn_minutes' in timeouts) {
166
+ if (typeof timeouts.per_turn_minutes !== 'number' || timeouts.per_turn_minutes < 1) {
167
+ errors.push('timeouts.per_turn_minutes must be a number >= 1');
168
+ }
169
+ }
170
+
171
+ if ('per_phase_minutes' in timeouts) {
172
+ if (typeof timeouts.per_phase_minutes !== 'number' || timeouts.per_phase_minutes < 1) {
173
+ errors.push('timeouts.per_phase_minutes must be a number >= 1');
174
+ }
175
+ }
176
+
177
+ if ('per_run_minutes' in timeouts) {
178
+ if (typeof timeouts.per_run_minutes !== 'number' || timeouts.per_run_minutes < 1) {
179
+ errors.push('timeouts.per_run_minutes must be a number >= 1');
180
+ }
181
+ }
182
+
183
+ if ('action' in timeouts) {
184
+ if (!VALID_TIMEOUT_ACTIONS.includes(timeouts.action)) {
185
+ errors.push(`timeouts.action must be one of: escalate, warn`);
186
+ }
187
+ if (timeouts.action === 'skip_phase') {
188
+ // skip_phase is only valid as a per-phase override, not as a global default
189
+ // because it makes no sense for per-turn or per-run timeouts
190
+ errors.push('timeouts.action cannot be "skip_phase" at the global level — use per-phase timeout_action override in routing instead');
191
+ }
192
+ }
193
+
194
+ // Per-phase timeout overrides in routing
195
+ if (routing) {
196
+ for (const [phase, route] of Object.entries(routing)) {
197
+ if ('timeout_minutes' in route) {
198
+ if (typeof route.timeout_minutes !== 'number' || route.timeout_minutes < 1) {
199
+ errors.push(`Routing "${phase}": timeout_minutes must be a number >= 1`);
200
+ }
201
+ }
202
+ if ('timeout_action' in route) {
203
+ if (!VALID_TIMEOUT_ACTIONS.includes(route.timeout_action)) {
204
+ errors.push(`Routing "${phase}": timeout_action must be one of: ${VALID_TIMEOUT_ACTIONS.join(', ')}`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return { ok: errors.length === 0, errors };
211
+ }
212
+
213
+ /**
214
+ * Build a blocked_reason descriptor for a timeout.
215
+ */
216
+ export function buildTimeoutBlockedReason(timeoutResult, options = {}) {
217
+ const scopeLabel = timeoutResult.scope === 'turn'
218
+ ? 'Turn timeout'
219
+ : timeoutResult.scope === 'phase'
220
+ ? `Phase timeout (${timeoutResult.phase || 'unknown'})`
221
+ : 'Run timeout';
222
+ const turnRetained = options.turnRetained === true;
223
+
224
+ return {
225
+ category: 'timeout',
226
+ recovery: {
227
+ typed_reason: 'timeout',
228
+ owner: 'operator',
229
+ recovery_action: 'agentxchain resume',
230
+ turn_retained: turnRetained,
231
+ detail: `${scopeLabel}: limit was ${timeoutResult.limit_minutes}m, elapsed ${timeoutResult.elapsed_minutes}m (exceeded by ${timeoutResult.exceeded_by_minutes}m)`,
232
+ },
233
+ };
234
+ }