agentxchain 2.20.0 → 2.22.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/package.json +1 -1
- package/scripts/release-preflight.sh +24 -1
- package/src/commands/demo.js +1 -0
- package/src/commands/escalate.js +1 -1
- package/src/commands/status.js +2 -2
- package/src/lib/adapters/api-proxy-adapter.js +10 -0
- package/src/lib/blocked-state.js +34 -6
- package/src/lib/config.js +11 -2
- package/src/lib/governed-state.js +280 -11
package/package.json
CHANGED
|
@@ -114,8 +114,31 @@ else
|
|
|
114
114
|
fi
|
|
115
115
|
TEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# pass / { print $3 }')"
|
|
116
116
|
TEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^# fail / { print $3 }')"
|
|
117
|
+
if [ -z "${TEST_PASS:-}" ]; then
|
|
118
|
+
VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
|
|
119
|
+
NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
|
|
120
|
+
if [ -n "${VITEST_PASS:-}" ] && [ -n "${NODE_PASS:-}" ]; then
|
|
121
|
+
TEST_PASS="$((VITEST_PASS + NODE_PASS))"
|
|
122
|
+
elif [ -n "${NODE_PASS:-}" ]; then
|
|
123
|
+
TEST_PASS="${NODE_PASS}"
|
|
124
|
+
elif [ -n "${VITEST_PASS:-}" ]; then
|
|
125
|
+
TEST_PASS="${VITEST_PASS}"
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
if [ -z "${TEST_FAIL:-}" ]; then
|
|
129
|
+
NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
|
|
130
|
+
if [ -n "${NODE_FAIL:-}" ]; then
|
|
131
|
+
TEST_FAIL="${NODE_FAIL}"
|
|
132
|
+
elif printf '%s\n' "$TEST_OUTPUT" | grep -Eq '^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed'; then
|
|
133
|
+
TEST_FAIL=0
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
117
136
|
if [ "$TEST_STATUS" -eq 0 ] && [ "${TEST_FAIL:-0}" = "0" ]; then
|
|
118
|
-
|
|
137
|
+
if [ -n "${TEST_PASS:-}" ]; then
|
|
138
|
+
pass "${TEST_PASS} tests passed, 0 failures"
|
|
139
|
+
else
|
|
140
|
+
pass "npm test passed, 0 failures"
|
|
141
|
+
fi
|
|
119
142
|
else
|
|
120
143
|
fail "npm test failed"
|
|
121
144
|
printf '%s\n' "$TEST_OUTPUT" | tail -20
|
package/src/commands/demo.js
CHANGED
|
@@ -605,6 +605,7 @@ All acceptance criteria met. OBJ-002 (clock skew) noted for follow-up. OBJ-003 (
|
|
|
605
605
|
console.log(chalk.dim(' ─'.repeat(26)));
|
|
606
606
|
console.log('');
|
|
607
607
|
console.log(` ${chalk.bold('Try it for real:')} agentxchain init --governed`);
|
|
608
|
+
console.log(` ${chalk.bold('Step by step:')} https://agentxchain.dev/docs/first-turn`);
|
|
608
609
|
console.log(` ${chalk.bold('Read more:')} https://agentxchain.dev/docs/quickstart`);
|
|
609
610
|
console.log('');
|
|
610
611
|
}
|
package/src/commands/escalate.js
CHANGED
|
@@ -35,7 +35,7 @@ export async function escalateCommand(opts) {
|
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const recovery = deriveRecoveryDescriptor(result.state);
|
|
38
|
+
const recovery = deriveRecoveryDescriptor(result.state, config);
|
|
39
39
|
|
|
40
40
|
console.log('');
|
|
41
41
|
console.log(chalk.yellow(' Run Escalated'));
|
package/src/commands/status.js
CHANGED
|
@@ -168,7 +168,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
168
168
|
if (state?.blocked_on) {
|
|
169
169
|
console.log('');
|
|
170
170
|
if (state.status === 'blocked') {
|
|
171
|
-
const recovery = deriveRecoveryDescriptor(state);
|
|
171
|
+
const recovery = deriveRecoveryDescriptor(state, config);
|
|
172
172
|
const detail = recovery?.detail || state.blocked_on;
|
|
173
173
|
console.log(` ${chalk.dim('Blocked:')} ${chalk.red.bold('BLOCKED')} — ${detail}`);
|
|
174
174
|
} else if (state.blocked_on.startsWith('human_approval:')) {
|
|
@@ -179,7 +179,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
const recovery = deriveRecoveryDescriptor(state);
|
|
182
|
+
const recovery = deriveRecoveryDescriptor(state, config);
|
|
183
183
|
if (recovery) {
|
|
184
184
|
console.log('');
|
|
185
185
|
console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
|
|
@@ -48,9 +48,19 @@ const PROVIDER_ENDPOINTS = {
|
|
|
48
48
|
|
|
49
49
|
// Cost rates per million tokens (USD)
|
|
50
50
|
const COST_RATES = {
|
|
51
|
+
// Anthropic
|
|
51
52
|
'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
|
|
52
53
|
'claude-opus-4-6': { input_per_1m: 15.00, output_per_1m: 75.00 },
|
|
53
54
|
'claude-haiku-4-5-20251001': { input_per_1m: 0.80, output_per_1m: 4.00 },
|
|
55
|
+
// OpenAI
|
|
56
|
+
'gpt-4o': { input_per_1m: 2.50, output_per_1m: 10.00 },
|
|
57
|
+
'gpt-4o-mini': { input_per_1m: 0.15, output_per_1m: 0.60 },
|
|
58
|
+
'gpt-4.1': { input_per_1m: 2.00, output_per_1m: 8.00 },
|
|
59
|
+
'gpt-4.1-mini': { input_per_1m: 0.40, output_per_1m: 1.60 },
|
|
60
|
+
'gpt-4.1-nano': { input_per_1m: 0.10, output_per_1m: 0.40 },
|
|
61
|
+
'o3': { input_per_1m: 2.00, output_per_1m: 8.00 },
|
|
62
|
+
'o3-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
|
|
63
|
+
'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
|
|
54
64
|
};
|
|
55
65
|
|
|
56
66
|
const RETRYABLE_ERROR_CLASSES = [
|
package/src/lib/blocked-state.js
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
-
import { getActiveTurnCount } from './governed-state.js';
|
|
1
|
+
import { deriveEscalationRecoveryAction, getActiveTurnCount } from './governed-state.js';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
function isLegacyEscalationRecoveryAction(action) {
|
|
4
|
+
return action === 'Resolve the escalation, then run agentxchain step --resume'
|
|
5
|
+
|| action === 'Resolve the escalation, then run agentxchain step';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained) {
|
|
9
|
+
if (!config || !persistedRecovery || typeof persistedRecovery !== 'object') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const typedReason = persistedRecovery.typed_reason;
|
|
14
|
+
const currentAction = persistedRecovery.recovery_action || null;
|
|
15
|
+
const shouldRefresh = typedReason === 'retries_exhausted'
|
|
16
|
+
|| ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction));
|
|
17
|
+
if (!shouldRefresh) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return deriveEscalationRecoveryAction(state, config, {
|
|
22
|
+
turnRetained,
|
|
23
|
+
turnId: state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deriveRecoveryDescriptor(state, config = null) {
|
|
4
28
|
if (!state || typeof state !== 'object') {
|
|
5
29
|
return null;
|
|
6
30
|
}
|
|
@@ -29,10 +53,13 @@ export function deriveRecoveryDescriptor(state) {
|
|
|
29
53
|
|
|
30
54
|
const persistedRecovery = state.blocked_reason?.recovery;
|
|
31
55
|
if (persistedRecovery && typeof persistedRecovery === 'object') {
|
|
56
|
+
const refreshedEscalationAction = maybeRefreshEscalationAction(state, config, persistedRecovery, turnRetained);
|
|
32
57
|
return {
|
|
33
58
|
typed_reason: persistedRecovery.typed_reason || 'unknown_block',
|
|
34
59
|
owner: persistedRecovery.owner || 'human',
|
|
35
|
-
recovery_action:
|
|
60
|
+
recovery_action: refreshedEscalationAction
|
|
61
|
+
|| persistedRecovery.recovery_action
|
|
62
|
+
|| 'Inspect state.json and resolve manually before rerunning agentxchain step',
|
|
36
63
|
turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
|
|
37
64
|
? persistedRecovery.turn_retained
|
|
38
65
|
: turnRetained,
|
|
@@ -66,9 +93,10 @@ export function deriveRecoveryDescriptor(state) {
|
|
|
66
93
|
|
|
67
94
|
if (state.blocked_on.startsWith('escalation:')) {
|
|
68
95
|
const isOperatorEscalation = state.blocked_on.startsWith('escalation:operator:') || state.escalation?.source === 'operator';
|
|
69
|
-
const recoveryAction =
|
|
70
|
-
|
|
71
|
-
:
|
|
96
|
+
const recoveryAction = deriveEscalationRecoveryAction(state, config, {
|
|
97
|
+
turnRetained,
|
|
98
|
+
turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
|
|
99
|
+
});
|
|
72
100
|
return {
|
|
73
101
|
typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
74
102
|
owner: 'human',
|
package/src/lib/config.js
CHANGED
|
@@ -3,7 +3,12 @@ import { join, parse as pathParse, resolve } from 'path';
|
|
|
3
3
|
import { safeParseJson, validateConfigSchema, validateLockSchema, validateProjectStateSchema, validateStateSchema } from './schema.js';
|
|
4
4
|
import { loadNormalizedConfig } from './normalized-config.js';
|
|
5
5
|
import { safeWriteJson } from './safe-write.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
normalizeGovernedStateShape,
|
|
8
|
+
getActiveTurn,
|
|
9
|
+
reconcileBudgetStatusWithConfig,
|
|
10
|
+
reconcileEscalationRecoveryWithConfig,
|
|
11
|
+
} from './governed-state.js';
|
|
7
12
|
|
|
8
13
|
function attachLegacyCurrentTurnAlias(state) {
|
|
9
14
|
if (!state || typeof state !== 'object') {
|
|
@@ -148,7 +153,11 @@ export function loadProjectState(root, config) {
|
|
|
148
153
|
if (config?.protocol_mode === 'governed') {
|
|
149
154
|
const normalized = normalizeGovernedStateShape(stateData);
|
|
150
155
|
stateData = normalized.state;
|
|
151
|
-
|
|
156
|
+
const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
|
|
157
|
+
stateData = reconciledBudget.state;
|
|
158
|
+
const reconciledRecovery = reconcileEscalationRecoveryWithConfig(stateData, config);
|
|
159
|
+
stateData = reconciledRecovery.state;
|
|
160
|
+
if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
152
161
|
safeWriteJson(filePath, stateData);
|
|
153
162
|
}
|
|
154
163
|
}
|
|
@@ -190,6 +190,68 @@ export function getActiveTurn(state) {
|
|
|
190
190
|
return turns.length === 1 ? turns[0] : null;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
function resolveRecoveryTurnId(state, preferredTurnId = null) {
|
|
194
|
+
const activeTurns = getActiveTurns(state);
|
|
195
|
+
if (preferredTurnId && activeTurns[preferredTurnId]) {
|
|
196
|
+
return preferredTurnId;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const blockedTurnId = state?.blocked_reason?.turn_id;
|
|
200
|
+
if (blockedTurnId && activeTurns[blockedTurnId]) {
|
|
201
|
+
return blockedTurnId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const escalationTurnId = state?.escalation?.from_turn_id;
|
|
205
|
+
if (escalationTurnId && activeTurns[escalationTurnId]) {
|
|
206
|
+
return escalationTurnId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const turnIds = Object.keys(activeTurns);
|
|
210
|
+
return turnIds.length === 1 ? turnIds[0] : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function deriveRetainedTurnRecoveryCommand(state, config, options = {}) {
|
|
214
|
+
const turnId = resolveRecoveryTurnId(state, options.turnId);
|
|
215
|
+
if (!turnId) {
|
|
216
|
+
return options.fallbackCommand || 'agentxchain step --resume';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const turn = getActiveTurns(state)[turnId];
|
|
220
|
+
const runtimeType = config?.runtimes?.[turn?.runtime_id]?.type || 'manual';
|
|
221
|
+
let command = runtimeType === 'manual'
|
|
222
|
+
? (options.manualCommand || 'agentxchain resume')
|
|
223
|
+
: (options.automatedCommand || 'agentxchain step --resume');
|
|
224
|
+
|
|
225
|
+
if (getActiveTurnCount(state) > 1) {
|
|
226
|
+
command += ` --turn ${turnId}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return command;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isLegacyEscalationRecoveryAction(action) {
|
|
233
|
+
return action === 'Resolve the escalation, then run agentxchain step --resume'
|
|
234
|
+
|| action === 'Resolve the escalation, then run agentxchain step';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function deriveEscalationRecoveryAction(state, config, options = {}) {
|
|
238
|
+
if (typeof options.overrideAction === 'string' && options.overrideAction.trim()) {
|
|
239
|
+
return options.overrideAction.trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const turnRetained = typeof options.turnRetained === 'boolean'
|
|
243
|
+
? options.turnRetained
|
|
244
|
+
: getActiveTurnCount(state) > 0;
|
|
245
|
+
const command = turnRetained
|
|
246
|
+
? deriveRetainedTurnRecoveryCommand(state, config, {
|
|
247
|
+
turnId: options.turnId,
|
|
248
|
+
fallbackCommand: 'agentxchain step --resume',
|
|
249
|
+
})
|
|
250
|
+
: 'agentxchain resume';
|
|
251
|
+
|
|
252
|
+
return `Resolve the escalation, then run ${command}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
193
255
|
export function getActiveTurnOrThrow(state) {
|
|
194
256
|
const turns = Object.values(getActiveTurns(state));
|
|
195
257
|
if (turns.length === 0) {
|
|
@@ -222,6 +284,91 @@ function attachLegacyCurrentTurnAlias(state) {
|
|
|
222
284
|
return state;
|
|
223
285
|
}
|
|
224
286
|
|
|
287
|
+
function formatBudgetRecoveryAction(isReadyToResume) {
|
|
288
|
+
return isReadyToResume
|
|
289
|
+
? 'Run agentxchain resume to assign the next turn'
|
|
290
|
+
: 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume) {
|
|
294
|
+
if (limitUsd == null) {
|
|
295
|
+
return isReadyToResume
|
|
296
|
+
? `Budget recovery ready: spent $${spentUsd.toFixed(2)} with per_run_max_usd disabled`
|
|
297
|
+
: `Run budget exhausted: spent $${spentUsd.toFixed(2)} with no configured per_run_max_usd limit`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (isReadyToResume) {
|
|
301
|
+
return `Budget recovery ready: spent $${spentUsd.toFixed(2)} of $${limitUsd.toFixed(2)} limit ($${remainingUsd.toFixed(2)} remaining)`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return `Run budget exhausted: spent $${spentUsd.toFixed(2)} of $${limitUsd.toFixed(2)} limit ($${Math.abs(remainingUsd).toFixed(2)} over)`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function reconcileBudgetStatusWithConfig(state, config) {
|
|
308
|
+
if (!state || typeof state !== 'object') {
|
|
309
|
+
return { state, changed: false };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const baseState = stripLegacyCurrentTurn(state);
|
|
313
|
+
const budgetStatus = baseState.budget_status && typeof baseState.budget_status === 'object' && !Array.isArray(baseState.budget_status)
|
|
314
|
+
? baseState.budget_status
|
|
315
|
+
: {};
|
|
316
|
+
const spentUsd = Number.isFinite(budgetStatus.spent_usd) ? budgetStatus.spent_usd : 0;
|
|
317
|
+
const limitUsd = Number.isFinite(config?.budget?.per_run_max_usd) ? config.budget.per_run_max_usd : null;
|
|
318
|
+
const remainingUsd = limitUsd != null ? limitUsd - spentUsd : null;
|
|
319
|
+
const nextBudgetStatus = {
|
|
320
|
+
...budgetStatus,
|
|
321
|
+
spent_usd: spentUsd,
|
|
322
|
+
remaining_usd: remainingUsd,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (remainingUsd != null && remainingUsd <= 0) {
|
|
326
|
+
nextBudgetStatus.exhausted = true;
|
|
327
|
+
if (budgetStatus.exhausted_at) {
|
|
328
|
+
nextBudgetStatus.exhausted_at = budgetStatus.exhausted_at;
|
|
329
|
+
}
|
|
330
|
+
if (budgetStatus.exhausted_after_turn) {
|
|
331
|
+
nextBudgetStatus.exhausted_after_turn = budgetStatus.exhausted_after_turn;
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
delete nextBudgetStatus.exhausted;
|
|
335
|
+
delete nextBudgetStatus.exhausted_at;
|
|
336
|
+
delete nextBudgetStatus.exhausted_after_turn;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let nextState = {
|
|
340
|
+
...baseState,
|
|
341
|
+
budget_status: nextBudgetStatus,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const isBudgetBlocked = nextState.blocked_on === 'budget:exhausted' || nextState.blocked_reason?.category === 'budget_exhausted';
|
|
345
|
+
if (isBudgetBlocked) {
|
|
346
|
+
const isReadyToResume = remainingUsd == null || remainingUsd > 0;
|
|
347
|
+
nextState = {
|
|
348
|
+
...nextState,
|
|
349
|
+
blocked_on: 'budget:exhausted',
|
|
350
|
+
blocked_reason: buildBlockedReason({
|
|
351
|
+
category: 'budget_exhausted',
|
|
352
|
+
recovery: {
|
|
353
|
+
typed_reason: 'budget_exhausted',
|
|
354
|
+
owner: 'human',
|
|
355
|
+
recovery_action: formatBudgetRecoveryAction(isReadyToResume),
|
|
356
|
+
turn_retained: false,
|
|
357
|
+
detail: formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume),
|
|
358
|
+
},
|
|
359
|
+
turnId: nextState.blocked_reason?.turn_id ?? nextState.last_completed_turn_id ?? null,
|
|
360
|
+
blockedAt: nextState.blocked_reason?.blocked_at,
|
|
361
|
+
}),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const changed = JSON.stringify(baseState) !== JSON.stringify(nextState);
|
|
366
|
+
return {
|
|
367
|
+
state: changed ? nextState : baseState,
|
|
368
|
+
changed,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
225
372
|
function normalizeV1toV1_1(state) {
|
|
226
373
|
const hadLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(state, 'current_turn');
|
|
227
374
|
const activeTurns = normalizeActiveTurns(state.active_turns);
|
|
@@ -789,6 +936,66 @@ function normalizeRecoveryDescriptor(recovery, turnRetained, detail) {
|
|
|
789
936
|
};
|
|
790
937
|
}
|
|
791
938
|
|
|
939
|
+
export function reconcileEscalationRecoveryWithConfig(state, config) {
|
|
940
|
+
if (!state || typeof state !== 'object' || state.status !== 'blocked' || !config) {
|
|
941
|
+
return { state, changed: false };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const recovery = state.blocked_reason?.recovery;
|
|
945
|
+
const typedReason = recovery?.typed_reason;
|
|
946
|
+
if (typedReason !== 'operator_escalation' && typedReason !== 'retries_exhausted') {
|
|
947
|
+
return { state, changed: false };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const currentAction = recovery?.recovery_action || null;
|
|
951
|
+
const shouldRefresh = typedReason === 'retries_exhausted' || isLegacyEscalationRecoveryAction(currentAction);
|
|
952
|
+
if (!shouldRefresh) {
|
|
953
|
+
return { state, changed: false };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const turnRetained = typeof recovery?.turn_retained === 'boolean'
|
|
957
|
+
? recovery.turn_retained
|
|
958
|
+
: getActiveTurnCount(state) > 0;
|
|
959
|
+
const nextAction = deriveEscalationRecoveryAction(state, config, {
|
|
960
|
+
turnRetained,
|
|
961
|
+
turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
let nextState = state;
|
|
965
|
+
let changed = false;
|
|
966
|
+
|
|
967
|
+
if (recovery && currentAction !== nextAction) {
|
|
968
|
+
nextState = {
|
|
969
|
+
...nextState,
|
|
970
|
+
blocked_reason: {
|
|
971
|
+
...nextState.blocked_reason,
|
|
972
|
+
recovery: {
|
|
973
|
+
...recovery,
|
|
974
|
+
recovery_action: nextAction,
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
changed = true;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (
|
|
982
|
+
nextState.escalation?.source === 'operator'
|
|
983
|
+
&& isLegacyEscalationRecoveryAction(nextState.escalation.recovery_action)
|
|
984
|
+
&& nextState.escalation.recovery_action !== nextAction
|
|
985
|
+
) {
|
|
986
|
+
nextState = {
|
|
987
|
+
...nextState,
|
|
988
|
+
escalation: {
|
|
989
|
+
...nextState.escalation,
|
|
990
|
+
recovery_action: nextAction,
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
changed = true;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return { state: nextState, changed };
|
|
997
|
+
}
|
|
998
|
+
|
|
792
999
|
function inferBlockedReasonFromState(state) {
|
|
793
1000
|
if (!state || typeof state !== 'object') {
|
|
794
1001
|
return null;
|
|
@@ -818,9 +1025,10 @@ function inferBlockedReasonFromState(state) {
|
|
|
818
1025
|
|
|
819
1026
|
if (state.blocked_on.startsWith('escalation:')) {
|
|
820
1027
|
const isOperatorEscalation = isOperatorEscalationBlockedOn(state.blocked_on) || state.escalation?.source === 'operator';
|
|
821
|
-
const recoveryAction =
|
|
822
|
-
|
|
823
|
-
:
|
|
1028
|
+
const recoveryAction = deriveEscalationRecoveryAction(state, null, {
|
|
1029
|
+
turnRetained,
|
|
1030
|
+
turnId: activeTurn?.turn_id ?? state.escalation?.from_turn_id ?? null,
|
|
1031
|
+
});
|
|
824
1032
|
return buildBlockedReason({
|
|
825
1033
|
category: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
|
|
826
1034
|
recovery: {
|
|
@@ -1001,10 +1209,11 @@ export function raiseOperatorEscalation(root, config, details) {
|
|
|
1001
1209
|
}
|
|
1002
1210
|
|
|
1003
1211
|
const turnRetained = Boolean(targetTurn);
|
|
1004
|
-
const recoveryAction =
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1212
|
+
const recoveryAction = deriveEscalationRecoveryAction(state, config, {
|
|
1213
|
+
turnRetained,
|
|
1214
|
+
turnId: targetTurn?.turn_id || null,
|
|
1215
|
+
overrideAction: details.action,
|
|
1216
|
+
});
|
|
1008
1217
|
const detail = typeof details.detail === 'string' && details.detail.trim()
|
|
1009
1218
|
? details.detail.trim()
|
|
1010
1219
|
: reason;
|
|
@@ -1161,11 +1370,17 @@ export function initializeGovernedRun(root, config) {
|
|
|
1161
1370
|
* @returns {{ ok: boolean, error?: string, warnings?: string[], state?: object }}
|
|
1162
1371
|
*/
|
|
1163
1372
|
export function assignGovernedTurn(root, config, roleId) {
|
|
1164
|
-
|
|
1373
|
+
let state = readState(root);
|
|
1165
1374
|
if (!state) {
|
|
1166
1375
|
return { ok: false, error: 'No governed state.json found' };
|
|
1167
1376
|
}
|
|
1168
1377
|
|
|
1378
|
+
const reconciledBudget = reconcileBudgetStatusWithConfig(state, config);
|
|
1379
|
+
if (reconciledBudget.changed) {
|
|
1380
|
+
state = reconciledBudget.state;
|
|
1381
|
+
writeState(root, state);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1169
1384
|
// DEC-PARALLEL-007: No new assignment while run is blocked
|
|
1170
1385
|
if (state.status === 'blocked') {
|
|
1171
1386
|
return { ok: false, error: 'Cannot assign turn: run is blocked. Resolve the blocked state before assigning new turns.' };
|
|
@@ -1206,6 +1421,11 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1206
1421
|
return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
|
|
1207
1422
|
}
|
|
1208
1423
|
|
|
1424
|
+
// DEC-BUDGET-ENFORCE-001: Pre-assignment budget exhaustion guard
|
|
1425
|
+
if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
|
|
1426
|
+
return { ok: false, error: `Cannot assign turn: run budget exhausted (spent $${(state.budget_status.spent_usd || 0).toFixed(2)} of $${((state.budget_status.spent_usd || 0) + state.budget_status.remaining_usd).toFixed(2)} limit). Increase per_run_max_usd in agentxchain.json, then run agentxchain resume` };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1209
1429
|
// DEC-PARALLEL-011: Budget reservation
|
|
1210
1430
|
const warnings = [];
|
|
1211
1431
|
const reservations = { ...(state.budget_reservations || {}) };
|
|
@@ -1807,6 +2027,44 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
1807
2027
|
});
|
|
1808
2028
|
}
|
|
1809
2029
|
|
|
2030
|
+
// DEC-BUDGET-ENFORCE-001: Post-acceptance budget exhaustion check
|
|
2031
|
+
// Per-turn overrun warning (advisory only)
|
|
2032
|
+
let budgetWarning = null;
|
|
2033
|
+
const turnReservation = state.budget_reservations?.[currentTurn.turn_id];
|
|
2034
|
+
if (turnReservation && costUsd > turnReservation.reserved_usd) {
|
|
2035
|
+
budgetWarning = `Actual cost $${costUsd.toFixed(2)} exceeded reservation $${turnReservation.reserved_usd.toFixed(2)} for this turn`;
|
|
2036
|
+
}
|
|
2037
|
+
// Budget exhaustion enforcement
|
|
2038
|
+
if (
|
|
2039
|
+
updatedState.budget_status.remaining_usd != null &&
|
|
2040
|
+
updatedState.budget_status.remaining_usd <= 0 &&
|
|
2041
|
+
updatedState.status !== 'blocked' &&
|
|
2042
|
+
updatedState.status !== 'completed'
|
|
2043
|
+
) {
|
|
2044
|
+
const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
|
|
2045
|
+
if (onExceed === 'pause_and_escalate') {
|
|
2046
|
+
const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
|
|
2047
|
+
const overBy = Math.abs(updatedState.budget_status.remaining_usd);
|
|
2048
|
+
updatedState.status = 'blocked';
|
|
2049
|
+
updatedState.blocked_on = 'budget:exhausted';
|
|
2050
|
+
updatedState.blocked_reason = buildBlockedReason({
|
|
2051
|
+
category: 'budget_exhausted',
|
|
2052
|
+
recovery: {
|
|
2053
|
+
typed_reason: 'budget_exhausted',
|
|
2054
|
+
owner: 'human',
|
|
2055
|
+
recovery_action: 'Increase per_run_max_usd in agentxchain.json, then run agentxchain resume',
|
|
2056
|
+
turn_retained: false,
|
|
2057
|
+
detail: `Run budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over)`,
|
|
2058
|
+
},
|
|
2059
|
+
turnId: currentTurn.turn_id,
|
|
2060
|
+
blockedAt: now,
|
|
2061
|
+
});
|
|
2062
|
+
updatedState.budget_status.exhausted = true;
|
|
2063
|
+
updatedState.budget_status.exhausted_at = now;
|
|
2064
|
+
updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1810
2068
|
let gateResult = null;
|
|
1811
2069
|
let completionResult = null;
|
|
1812
2070
|
const hasRemainingTurns = Object.keys(remainingTurns).length > 0;
|
|
@@ -2030,6 +2288,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2030
2288
|
gateResult,
|
|
2031
2289
|
completionResult,
|
|
2032
2290
|
hookResults,
|
|
2291
|
+
...(budgetWarning ? { budget_warning: budgetWarning } : {}),
|
|
2033
2292
|
};
|
|
2034
2293
|
}
|
|
2035
2294
|
|
|
@@ -2171,7 +2430,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
2171
2430
|
}
|
|
2172
2431
|
|
|
2173
2432
|
// Retries exhausted — escalate
|
|
2174
|
-
const
|
|
2433
|
+
const exhaustedEscalationState = {
|
|
2175
2434
|
...state,
|
|
2176
2435
|
status: 'blocked',
|
|
2177
2436
|
active_turns: {
|
|
@@ -2185,12 +2444,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
2185
2444
|
},
|
|
2186
2445
|
},
|
|
2187
2446
|
blocked_on: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
const updatedState = {
|
|
2450
|
+
...exhaustedEscalationState,
|
|
2188
2451
|
blocked_reason: buildBlockedReason({
|
|
2189
2452
|
category: 'retries_exhausted',
|
|
2190
2453
|
recovery: {
|
|
2191
2454
|
typed_reason: 'retries_exhausted',
|
|
2192
2455
|
owner: 'human',
|
|
2193
|
-
recovery_action:
|
|
2456
|
+
recovery_action: deriveEscalationRecoveryAction(exhaustedEscalationState, config, {
|
|
2457
|
+
turnRetained: true,
|
|
2458
|
+
turnId: currentTurn.turn_id,
|
|
2459
|
+
}),
|
|
2194
2460
|
turn_retained: true,
|
|
2195
2461
|
detail: `escalation:retries-exhausted:${currentTurn.assigned_role}`,
|
|
2196
2462
|
},
|
|
@@ -2218,7 +2484,10 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
2218
2484
|
if (hooksConfig.on_escalation?.length > 0) {
|
|
2219
2485
|
_fireOnEscalationHooks(root, hooksConfig, {
|
|
2220
2486
|
blocked_reason: 'retries_exhausted',
|
|
2221
|
-
recovery_action:
|
|
2487
|
+
recovery_action: deriveEscalationRecoveryAction(updatedState, config, {
|
|
2488
|
+
turnRetained: true,
|
|
2489
|
+
turnId: currentTurn.turn_id,
|
|
2490
|
+
}),
|
|
2222
2491
|
failed_turn_id: currentTurn.turn_id,
|
|
2223
2492
|
failed_role: currentTurn.assigned_role,
|
|
2224
2493
|
attempt_count: currentAttempt,
|