agentxchain 2.60.0 → 2.62.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/bin/agentxchain.js +1 -0
- package/package.json +1 -1
- package/src/commands/accept-turn.js +3 -0
- package/src/commands/config.js +57 -4
- package/src/commands/restart.js +3 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/status.js +4 -1
- package/src/commands/step.js +7 -0
- package/src/lib/governed-state.js +44 -11
- package/src/lib/normalized-config.js +81 -0
- package/src/lib/report.js +17 -2
- package/src/lib/run-events.js +1 -0
package/bin/agentxchain.js
CHANGED
|
@@ -192,6 +192,7 @@ program
|
|
|
192
192
|
.description('View or edit project configuration')
|
|
193
193
|
.option('--add-agent', 'Add a new agent interactively')
|
|
194
194
|
.option('--remove-agent <id>', 'Remove an agent by ID')
|
|
195
|
+
.option('--get <path>', 'Read a config value (e.g. --get project.goal)')
|
|
195
196
|
.option('--set <path_and_value...>', 'Set a config value (e.g. --set project.goal "Build a governed CLI")')
|
|
196
197
|
.option('-j, --json', 'Output config as JSON')
|
|
197
198
|
.action(configCommand);
|
package/package.json
CHANGED
|
@@ -166,6 +166,9 @@ export async function acceptTurnCommand(opts = {}) {
|
|
|
166
166
|
if (accepted?.cost?.usd != null) {
|
|
167
167
|
console.log(` ${chalk.dim('Cost:')} $${formatUsd(accepted.cost.usd)}`);
|
|
168
168
|
}
|
|
169
|
+
if (result.budget_warning) {
|
|
170
|
+
console.log(` ${chalk.yellow('Budget warning:')} ${result.budget_warning}`);
|
|
171
|
+
}
|
|
169
172
|
console.log('');
|
|
170
173
|
|
|
171
174
|
const recovery = deriveRecoveryDescriptor(result.state);
|
package/src/commands/config.js
CHANGED
|
@@ -17,6 +17,12 @@ export async function configCommand(opts) {
|
|
|
17
17
|
const config = rawConfig;
|
|
18
18
|
const configPath = join(root, CONFIG_FILE);
|
|
19
19
|
|
|
20
|
+
if (opts.get && opts.set) {
|
|
21
|
+
console.log(chalk.red(' --get and --set are mutually exclusive.'));
|
|
22
|
+
console.log(chalk.dim(' Inspect a value with `agentxchain config --get <path>` or change it with `agentxchain config --set <path> <value>`.'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
if (version === 4 && opts.addAgent) {
|
|
21
27
|
printLegacyOnlyMutationError('--add-agent');
|
|
22
28
|
return;
|
|
@@ -37,6 +43,11 @@ export async function configCommand(opts) {
|
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
if (opts.get) {
|
|
47
|
+
getSetting(config, opts.get, { json: opts.json });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
if (opts.set) {
|
|
41
52
|
setSetting(config, configPath, opts.set, { version, root });
|
|
42
53
|
return;
|
|
@@ -81,6 +92,7 @@ function printLegacyConfig(config) {
|
|
|
81
92
|
console.log(chalk.dim(' Commands:'));
|
|
82
93
|
console.log(` ${chalk.bold('agentxchain config --add-agent')} Add a new agent`);
|
|
83
94
|
console.log(` ${chalk.bold('agentxchain config --remove-agent <id>')} Remove an agent`);
|
|
95
|
+
console.log(` ${chalk.bold('agentxchain config --get <key>')} Read one config value`);
|
|
84
96
|
console.log(` ${chalk.bold('agentxchain config --set <key> <val>')} Update a setting`);
|
|
85
97
|
console.log(` ${chalk.bold('agentxchain config --json')} Output as JSON`);
|
|
86
98
|
console.log('');
|
|
@@ -99,6 +111,7 @@ function printGovernedConfig(config) {
|
|
|
99
111
|
console.log(` ${chalk.dim('Runtimes:')} ${Object.keys(config.runtimes || {}).length}`);
|
|
100
112
|
console.log('');
|
|
101
113
|
console.log(chalk.dim(' Commands:'));
|
|
114
|
+
console.log(` ${chalk.bold('agentxchain config --get project.goal')} Read one config value without opening JSON`);
|
|
102
115
|
console.log(` ${chalk.bold('agentxchain config --set project.goal "Build a ..."')} Set mission context without hand-editing JSON`);
|
|
103
116
|
console.log(` ${chalk.bold('agentxchain config --set roles.qa.runtime manual-qa')} Switch a governed role runtime`);
|
|
104
117
|
console.log(` ${chalk.bold('agentxchain config --json')} Output raw config`);
|
|
@@ -169,10 +182,8 @@ function setSetting(config, configPath, keyValPair, context) {
|
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
const { key, rawVal } = parsed;
|
|
172
|
-
const segments = key
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (segments.some(segment => forbiddenKeys.has(segment))) {
|
|
185
|
+
const segments = parseKeyPath(key);
|
|
186
|
+
if (!segments) {
|
|
176
187
|
console.log(chalk.red(' Refusing to write reserved object path.'));
|
|
177
188
|
process.exit(1);
|
|
178
189
|
}
|
|
@@ -211,6 +222,35 @@ function setSetting(config, configPath, keyValPair, context) {
|
|
|
211
222
|
console.log('');
|
|
212
223
|
}
|
|
213
224
|
|
|
225
|
+
function getSetting(config, key, opts = {}) {
|
|
226
|
+
const segments = parseKeyPath(key);
|
|
227
|
+
if (!segments) {
|
|
228
|
+
console.log(chalk.red(' Refusing to read reserved object path.'));
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let value = config;
|
|
233
|
+
for (const segment of segments) {
|
|
234
|
+
if (value === null || typeof value !== 'object' || !(segment in value)) {
|
|
235
|
+
console.log(chalk.red(` Config path not found: ${key}`));
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
value = value[segment];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (opts.json) {
|
|
242
|
+
console.log(JSON.stringify(value, null, 2));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (value !== null && typeof value === 'object') {
|
|
247
|
+
console.log(JSON.stringify(value, null, 2));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(String(value));
|
|
252
|
+
}
|
|
253
|
+
|
|
214
254
|
function parseSetInput(input) {
|
|
215
255
|
if (Array.isArray(input)) {
|
|
216
256
|
if (input.length >= 2) {
|
|
@@ -235,6 +275,19 @@ function parseSetInput(input) {
|
|
|
235
275
|
return null;
|
|
236
276
|
}
|
|
237
277
|
|
|
278
|
+
function parseKeyPath(input) {
|
|
279
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const segments = input.split('.');
|
|
284
|
+
const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
285
|
+
if (segments.some(segment => segment === '' || forbiddenKeys.has(segment))) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return segments;
|
|
289
|
+
}
|
|
290
|
+
|
|
238
291
|
function validateEditedConfig(config, context) {
|
|
239
292
|
if (context.version === 4) {
|
|
240
293
|
return validateV4Config(config, context.root);
|
package/src/commands/restart.js
CHANGED
|
@@ -297,6 +297,9 @@ export async function restartCommand(opts) {
|
|
|
297
297
|
console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
|
|
298
298
|
process.exit(1);
|
|
299
299
|
}
|
|
300
|
+
for (const warning of assignment.warnings || []) {
|
|
301
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
302
|
+
}
|
|
300
303
|
|
|
301
304
|
// assignGovernedTurn already writes a checkpoint at turn_assigned
|
|
302
305
|
|
package/src/commands/resume.js
CHANGED
|
@@ -259,6 +259,7 @@ export async function resumeCommand(opts) {
|
|
|
259
259
|
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
260
260
|
process.exit(1);
|
|
261
261
|
}
|
|
262
|
+
printAssignmentWarnings(assignResult);
|
|
262
263
|
state = assignResult.state;
|
|
263
264
|
|
|
264
265
|
// Write dispatch bundle
|
|
@@ -415,6 +416,12 @@ function printDispatchBundleWarnings(bundleResult) {
|
|
|
415
416
|
}
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
function printAssignmentWarnings(assignResult) {
|
|
420
|
+
for (const warning of assignResult.warnings || []) {
|
|
421
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
418
425
|
function printAssignmentHookFailure(result, roleId) {
|
|
419
426
|
const recovery = deriveRecoveryDescriptor(result.state);
|
|
420
427
|
const hookName = result.hookResults?.blocker?.hook_name
|
package/src/commands/status.js
CHANGED
|
@@ -277,7 +277,10 @@ function renderGovernedStatus(context, opts) {
|
|
|
277
277
|
|
|
278
278
|
if (state?.budget_status) {
|
|
279
279
|
console.log('');
|
|
280
|
-
|
|
280
|
+
const budgetLabel = state.budget_status.warn_mode
|
|
281
|
+
? `spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)} ${chalk.yellow('[OVER BUDGET]')}`
|
|
282
|
+
: `spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`;
|
|
283
|
+
console.log(` ${chalk.dim('Budget:')} ${budgetLabel}`);
|
|
281
284
|
}
|
|
282
285
|
|
|
283
286
|
console.log('');
|
package/src/commands/step.js
CHANGED
|
@@ -293,6 +293,7 @@ export async function stepCommand(opts) {
|
|
|
293
293
|
console.log(chalk.red(`Failed to assign turn: ${assignResult.error}`));
|
|
294
294
|
process.exit(1);
|
|
295
295
|
}
|
|
296
|
+
printAssignmentWarnings(assignResult);
|
|
296
297
|
state = assignResult.state;
|
|
297
298
|
|
|
298
299
|
const bundleResult = writeDispatchBundle(root, state, config);
|
|
@@ -948,6 +949,12 @@ function printDispatchBundleWarnings(bundleResult) {
|
|
|
948
949
|
}
|
|
949
950
|
}
|
|
950
951
|
|
|
952
|
+
function printAssignmentWarnings(assignResult) {
|
|
953
|
+
for (const warning of assignResult.warnings || []) {
|
|
954
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
951
958
|
function printAssignmentHookFailure(result, roleId) {
|
|
952
959
|
const recovery = deriveRecoveryDescriptor(result.state);
|
|
953
960
|
const hookName = result.hookResults?.blocker?.hook_name
|
|
@@ -575,7 +575,7 @@ function attachLegacyCurrentTurnAlias(state) {
|
|
|
575
575
|
function formatBudgetRecoveryAction(isReadyToResume) {
|
|
576
576
|
return isReadyToResume
|
|
577
577
|
? 'Run agentxchain resume to assign the next turn'
|
|
578
|
-
: 'Increase
|
|
578
|
+
: 'Increase budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume';
|
|
579
579
|
}
|
|
580
580
|
|
|
581
581
|
function formatBudgetRecoveryDetail(spentUsd, limitUsd, remainingUsd, isReadyToResume) {
|
|
@@ -1936,13 +1936,19 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1936
1936
|
return { ok: false, error: `Cannot assign turn: ${activeCount} active turn(s) already at capacity (max_concurrent_turns = ${maxConcurrent})` };
|
|
1937
1937
|
}
|
|
1938
1938
|
|
|
1939
|
-
// DEC-BUDGET-ENFORCE-001: Pre-assignment budget exhaustion guard
|
|
1940
|
-
if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
|
|
1941
|
-
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` };
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
1939
|
// DEC-PARALLEL-011: Budget reservation
|
|
1945
1940
|
const warnings = [];
|
|
1941
|
+
|
|
1942
|
+
// DEC-BUDGET-ENFORCE-001 + DEC-BUDGET-WARN-001: Pre-assignment budget exhaustion guard
|
|
1943
|
+
if (state.budget_status?.remaining_usd != null && state.budget_status.remaining_usd <= 0) {
|
|
1944
|
+
const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
|
|
1945
|
+
if (onExceed === 'warn') {
|
|
1946
|
+
// Allow assignment but add a warning
|
|
1947
|
+
warnings.push(`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). Run continues in warn mode per on_exceed policy.`);
|
|
1948
|
+
} else {
|
|
1949
|
+
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 budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume` };
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1946
1952
|
const reservations = { ...(state.budget_reservations || {}) };
|
|
1947
1953
|
const turnId = generateId('turn');
|
|
1948
1954
|
const estimatedCost = estimateTurnBudget(config, roleId);
|
|
@@ -1950,7 +1956,8 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
1950
1956
|
if (estimatedCost > 0 && state.budget_status?.remaining_usd != null) {
|
|
1951
1957
|
const alreadyReserved = Object.values(reservations).reduce((sum, r) => sum + (r.reserved_usd || 0), 0);
|
|
1952
1958
|
const available = state.budget_status.remaining_usd - alreadyReserved;
|
|
1953
|
-
|
|
1959
|
+
const onExceedReserve = config.budget?.on_exceed || 'pause_and_escalate';
|
|
1960
|
+
if (estimatedCost > available && onExceedReserve !== 'warn') {
|
|
1954
1961
|
return { ok: false, error: `Cannot assign turn: estimated cost $${estimatedCost.toFixed(2)} exceeds available budget $${available.toFixed(2)} (after reservations)` };
|
|
1955
1962
|
}
|
|
1956
1963
|
reservations[turnId] = {
|
|
@@ -2617,6 +2624,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2617
2624
|
accepted_integration_ref: derivedRef,
|
|
2618
2625
|
next_recommended_role: deriveNextRecommendedRole(turnResult, state, config),
|
|
2619
2626
|
budget_status: {
|
|
2627
|
+
...(state.budget_status || {}),
|
|
2620
2628
|
spent_usd: (state.budget_status?.spent_usd || 0) + costUsd,
|
|
2621
2629
|
remaining_usd: state.budget_status?.remaining_usd != null
|
|
2622
2630
|
? state.budget_status.remaining_usd - costUsd
|
|
@@ -2657,7 +2665,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2657
2665
|
if (turnReservation && costUsd > turnReservation.reserved_usd) {
|
|
2658
2666
|
budgetWarning = `Actual cost $${costUsd.toFixed(2)} exceeded reservation $${turnReservation.reserved_usd.toFixed(2)} for this turn`;
|
|
2659
2667
|
}
|
|
2660
|
-
// Budget exhaustion enforcement
|
|
2668
|
+
// Budget exhaustion enforcement (DEC-BUDGET-ENFORCE-001 + DEC-BUDGET-WARN-001)
|
|
2661
2669
|
if (
|
|
2662
2670
|
updatedState.budget_status.remaining_usd != null &&
|
|
2663
2671
|
updatedState.budget_status.remaining_usd <= 0 &&
|
|
@@ -2665,9 +2673,9 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2665
2673
|
updatedState.status !== 'completed'
|
|
2666
2674
|
) {
|
|
2667
2675
|
const onExceed = config.budget?.on_exceed || 'pause_and_escalate';
|
|
2676
|
+
const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
|
|
2677
|
+
const overBy = Math.abs(updatedState.budget_status.remaining_usd);
|
|
2668
2678
|
if (onExceed === 'pause_and_escalate') {
|
|
2669
|
-
const limit = (updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd);
|
|
2670
|
-
const overBy = Math.abs(updatedState.budget_status.remaining_usd);
|
|
2671
2679
|
updatedState.status = 'blocked';
|
|
2672
2680
|
updatedState.blocked_on = 'budget:exhausted';
|
|
2673
2681
|
updatedState.blocked_reason = buildBlockedReason({
|
|
@@ -2675,7 +2683,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2675
2683
|
recovery: {
|
|
2676
2684
|
typed_reason: 'budget_exhausted',
|
|
2677
2685
|
owner: 'human',
|
|
2678
|
-
recovery_action: 'Increase
|
|
2686
|
+
recovery_action: 'Increase budget with agentxchain config --set budget.per_run_max_usd <usd>, then run agentxchain resume',
|
|
2679
2687
|
turn_retained: false,
|
|
2680
2688
|
detail: `Run budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over)`,
|
|
2681
2689
|
},
|
|
@@ -2685,6 +2693,15 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2685
2693
|
updatedState.budget_status.exhausted = true;
|
|
2686
2694
|
updatedState.budget_status.exhausted_at = now;
|
|
2687
2695
|
updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
|
|
2696
|
+
} else if (onExceed === 'warn') {
|
|
2697
|
+
// DEC-BUDGET-WARN-001: Do not block — mark exhaustion and emit warning
|
|
2698
|
+
if (!updatedState.budget_status.exhausted) {
|
|
2699
|
+
updatedState.budget_status.exhausted = true;
|
|
2700
|
+
updatedState.budget_status.exhausted_at = now;
|
|
2701
|
+
updatedState.budget_status.exhausted_after_turn = currentTurn.turn_id;
|
|
2702
|
+
}
|
|
2703
|
+
updatedState.budget_status.warn_mode = true;
|
|
2704
|
+
budgetWarning = `Budget exhausted: spent $${updatedState.budget_status.spent_usd.toFixed(2)} of $${limit.toFixed(2)} limit ($${overBy.toFixed(2)} over). Run continues in warn mode.`;
|
|
2688
2705
|
}
|
|
2689
2706
|
}
|
|
2690
2707
|
|
|
@@ -3081,6 +3098,22 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3081
3098
|
});
|
|
3082
3099
|
}
|
|
3083
3100
|
|
|
3101
|
+
// DEC-BUDGET-WARN-001: Emit budget_exceeded_warn event when warn mode triggers
|
|
3102
|
+
if (updatedState.budget_status?.warn_mode && budgetWarning) {
|
|
3103
|
+
emitRunEvent(root, 'budget_exceeded_warn', {
|
|
3104
|
+
run_id: updatedState.run_id,
|
|
3105
|
+
phase: updatedState.phase,
|
|
3106
|
+
status: updatedState.status,
|
|
3107
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3108
|
+
payload: {
|
|
3109
|
+
spent_usd: updatedState.budget_status.spent_usd,
|
|
3110
|
+
limit_usd: updatedState.budget_status.spent_usd + updatedState.budget_status.remaining_usd,
|
|
3111
|
+
remaining_usd: updatedState.budget_status.remaining_usd,
|
|
3112
|
+
warning: budgetWarning,
|
|
3113
|
+
},
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3084
3117
|
if (updatedState.pending_phase_transition) {
|
|
3085
3118
|
emitPendingLifecycleNotification(root, config, updatedState, 'phase_transition_pending', {
|
|
3086
3119
|
from: updatedState.pending_phase_transition.from,
|
|
@@ -61,6 +61,7 @@ const VALID_API_PROXY_PREFLIGHT_FIELDS = [
|
|
|
61
61
|
'tokenizer',
|
|
62
62
|
'safety_margin_tokens',
|
|
63
63
|
];
|
|
64
|
+
const VALID_BUDGET_ON_EXCEED = ['pause_and_escalate', 'warn'];
|
|
64
65
|
|
|
65
66
|
function validateMcpRuntime(runtimeId, runtime, errors) {
|
|
66
67
|
const transport = typeof runtime?.transport === 'string' && runtime.transport.trim()
|
|
@@ -537,6 +538,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
537
538
|
errors.push(...policyValidation.errors);
|
|
538
539
|
}
|
|
539
540
|
|
|
541
|
+
// Budget (optional but validated if present)
|
|
542
|
+
if (data.budget !== undefined) {
|
|
543
|
+
const budgetValidation = validateBudgetConfig(data.budget);
|
|
544
|
+
errors.push(...budgetValidation.errors);
|
|
545
|
+
}
|
|
546
|
+
|
|
540
547
|
// Approval Policy (optional but validated if present)
|
|
541
548
|
if (data.approval_policy !== undefined) {
|
|
542
549
|
errors.push(...validateApprovalPolicy(data.approval_policy, data.routing));
|
|
@@ -551,6 +558,80 @@ export function validateV4Config(data, projectRoot) {
|
|
|
551
558
|
return { ok: errors.length === 0, errors };
|
|
552
559
|
}
|
|
553
560
|
|
|
561
|
+
export function validateBudgetConfig(budget) {
|
|
562
|
+
const errors = [];
|
|
563
|
+
|
|
564
|
+
if (budget === null) {
|
|
565
|
+
return { ok: true, errors };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!budget || typeof budget !== 'object' || Array.isArray(budget)) {
|
|
569
|
+
errors.push('budget must be an object');
|
|
570
|
+
return { ok: false, errors };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
validateBudgetUsdLimit('budget.per_turn_max_usd', budget.per_turn_max_usd, errors);
|
|
574
|
+
validateBudgetUsdLimit('budget.per_run_max_usd', budget.per_run_max_usd, errors);
|
|
575
|
+
|
|
576
|
+
if (
|
|
577
|
+
Number.isFinite(budget.per_turn_max_usd) &&
|
|
578
|
+
Number.isFinite(budget.per_run_max_usd) &&
|
|
579
|
+
budget.per_turn_max_usd > budget.per_run_max_usd
|
|
580
|
+
) {
|
|
581
|
+
errors.push('budget.per_turn_max_usd must be less than or equal to budget.per_run_max_usd when both are set');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (budget.on_exceed !== undefined) {
|
|
585
|
+
if (typeof budget.on_exceed !== 'string' || !VALID_BUDGET_ON_EXCEED.includes(budget.on_exceed)) {
|
|
586
|
+
errors.push(`budget.on_exceed must be one of: ${VALID_BUDGET_ON_EXCEED.join(', ')}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (budget.cost_rates !== undefined) {
|
|
591
|
+
if (!budget.cost_rates || typeof budget.cost_rates !== 'object' || Array.isArray(budget.cost_rates)) {
|
|
592
|
+
errors.push('budget.cost_rates must be an object');
|
|
593
|
+
} else {
|
|
594
|
+
for (const [model, rates] of Object.entries(budget.cost_rates)) {
|
|
595
|
+
if (typeof model !== 'string' || !model.trim()) {
|
|
596
|
+
errors.push('budget.cost_rates model keys must be non-empty strings');
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (!rates || typeof rates !== 'object' || Array.isArray(rates)) {
|
|
600
|
+
errors.push(`budget.cost_rates.${model} must be an object`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
validateBudgetCostRate(`budget.cost_rates.${model}.input_per_1m`, rates.input_per_1m, errors);
|
|
604
|
+
validateBudgetCostRate(`budget.cost_rates.${model}.output_per_1m`, rates.output_per_1m, errors);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return { ok: errors.length === 0, errors };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function validateBudgetUsdLimit(path, value, errors) {
|
|
613
|
+
if (value === undefined || value === null) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
617
|
+
errors.push(`${path} must be a finite number when provided`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (value <= 0) {
|
|
621
|
+
errors.push(`${path} must be greater than 0 when provided`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function validateBudgetCostRate(path, value, errors) {
|
|
626
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
627
|
+
errors.push(`${path} must be a finite number`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (value < 0) {
|
|
631
|
+
errors.push(`${path} must be greater than or equal to 0`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
554
635
|
export function validateSchedulesConfig(schedules, roles) {
|
|
555
636
|
const errors = [];
|
|
556
637
|
|
package/src/lib/report.js
CHANGED
|
@@ -45,6 +45,19 @@ function normalizeBudgetStatus(budgetStatus) {
|
|
|
45
45
|
if (Number.isFinite(budgetStatus.remaining_usd)) {
|
|
46
46
|
normalized.remaining_usd = budgetStatus.remaining_usd;
|
|
47
47
|
}
|
|
48
|
+
// DEC-BUDGET-WARN-004: preserve warn-mode and exhaustion fields
|
|
49
|
+
if (budgetStatus.warn_mode === true) {
|
|
50
|
+
normalized.warn_mode = true;
|
|
51
|
+
}
|
|
52
|
+
if (budgetStatus.exhausted === true) {
|
|
53
|
+
normalized.exhausted = true;
|
|
54
|
+
}
|
|
55
|
+
if (budgetStatus.exhausted_at) {
|
|
56
|
+
normalized.exhausted_at = budgetStatus.exhausted_at;
|
|
57
|
+
}
|
|
58
|
+
if (budgetStatus.exhausted_after_turn) {
|
|
59
|
+
normalized.exhausted_after_turn = budgetStatus.exhausted_after_turn;
|
|
60
|
+
}
|
|
48
61
|
|
|
49
62
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
50
63
|
}
|
|
@@ -890,8 +903,9 @@ export function formatGovernanceReportText(report) {
|
|
|
890
903
|
];
|
|
891
904
|
|
|
892
905
|
if (run.budget_status) {
|
|
906
|
+
const warnTag = run.budget_status.warn_mode ? ' [OVER BUDGET]' : '';
|
|
893
907
|
lines.push(
|
|
894
|
-
`Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}`,
|
|
908
|
+
`Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}${warnTag}`,
|
|
895
909
|
);
|
|
896
910
|
}
|
|
897
911
|
|
|
@@ -1300,7 +1314,8 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1300
1314
|
];
|
|
1301
1315
|
|
|
1302
1316
|
if (run.budget_status) {
|
|
1303
|
-
|
|
1317
|
+
const warnTag = run.budget_status.warn_mode ? ' **[OVER BUDGET]**' : '';
|
|
1318
|
+
lines.push(`- Budget: spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}${warnTag}`);
|
|
1304
1319
|
}
|
|
1305
1320
|
|
|
1306
1321
|
if (run.created_at) {
|