agentxchain 2.156.0 → 2.157.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
CHANGED
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
parseRateLimitResetMs,
|
|
45
45
|
resolveClaudeCompatibleNodeBinary,
|
|
46
46
|
} from '../claude-local-auth.js';
|
|
47
|
+
import {
|
|
48
|
+
createStreamJsonCostParser,
|
|
49
|
+
buildCostFromStreamJson,
|
|
50
|
+
} from '../stream-json-cost-parser.js';
|
|
47
51
|
|
|
48
52
|
const DIAGNOSTIC_ENV_KEYS = [
|
|
49
53
|
'PATH',
|
|
@@ -147,6 +151,16 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Create stream-json cost parser for Claude runtimes with stream-json output.
|
|
155
|
+
// Only Claude runtimes emit parseable NDJSON with result events; other runtimes
|
|
156
|
+
// (Codex, Cursor, Windsurf, OpenCode) have different stdout formats.
|
|
157
|
+
const tokens = [command, ...args].filter((t) => typeof t === 'string');
|
|
158
|
+
const usesStreamJson = tokens.includes('--output-format=stream-json')
|
|
159
|
+
|| (tokens.includes('--output-format') && tokens[tokens.indexOf('--output-format') + 1] === 'stream-json');
|
|
160
|
+
const costParser = isClaudeLocalCliRuntime(runtime) && usesStreamJson
|
|
161
|
+
? createStreamJsonCostParser()
|
|
162
|
+
: null;
|
|
163
|
+
|
|
150
164
|
// Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
|
|
151
165
|
const timeoutMs = options.dispatchTimeoutMs != null
|
|
152
166
|
? options.dispatchTimeoutMs
|
|
@@ -425,6 +439,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
425
439
|
stdoutBytes += Buffer.byteLength(text);
|
|
426
440
|
recordFirstOutput('stdout');
|
|
427
441
|
logs.push(text);
|
|
442
|
+
if (costParser) costParser.push(text);
|
|
428
443
|
if (onStdout) onStdout(text);
|
|
429
444
|
});
|
|
430
445
|
}
|
|
@@ -558,6 +573,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
|
|
|
558
573
|
appendDiagnostic(logs, 'process_exit', exitDiagnostic);
|
|
559
574
|
|
|
560
575
|
if (hasResult) {
|
|
576
|
+
// Enrich cost from stream-json if available (best-effort, never blocks settle)
|
|
577
|
+
if (costParser) {
|
|
578
|
+
try {
|
|
579
|
+
const parsedCost = costParser.getResult();
|
|
580
|
+
if (parsedCost) {
|
|
581
|
+
const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
|
|
582
|
+
const raw = readFileSync(stagingPath, 'utf8');
|
|
583
|
+
const turnResult = JSON.parse(raw);
|
|
584
|
+
turnResult.cost = buildCostFromStreamJson(parsedCost, config);
|
|
585
|
+
writeFileSync(stagingPath, JSON.stringify(turnResult, null, 2) + '\n');
|
|
586
|
+
appendDiagnostic(logs, 'stream_json_cost_enriched', {
|
|
587
|
+
input_tokens: parsedCost.input_tokens,
|
|
588
|
+
output_tokens: parsedCost.output_tokens,
|
|
589
|
+
cost_usd: parsedCost.cost_usd,
|
|
590
|
+
model: parsedCost.model,
|
|
591
|
+
computed_usd: turnResult.cost.usd,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
// Cost enrichment is best-effort — if it fails, agent-reported cost is preserved
|
|
596
|
+
}
|
|
597
|
+
}
|
|
561
598
|
settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
|
|
562
599
|
} else if (isClaudeLocalCliRuntime(runtime) && hasClaudeAuthFailureOutput(logs)) {
|
|
563
600
|
const recovery = 'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
|
package/src/lib/ci-reporter.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Normalize gate_summary to [gateName, statusString] entries.
|
|
5
|
+
* Handles both array format ([{ gate_id, status }]) from the real report pipeline
|
|
6
|
+
* and object format ({ gateName: statusString }) from legacy/synthetic fixtures.
|
|
7
|
+
*/
|
|
8
|
+
function normalizeGateEntries(gates) {
|
|
9
|
+
if (Array.isArray(gates)) {
|
|
10
|
+
return gates
|
|
11
|
+
.filter(g => typeof g?.gate_id === 'string' && typeof g?.status === 'string')
|
|
12
|
+
.map(g => [g.gate_id, g.status]);
|
|
13
|
+
}
|
|
14
|
+
if (gates && typeof gates === 'object') {
|
|
15
|
+
return Object.entries(gates).filter(([, v]) => typeof v === 'string');
|
|
16
|
+
}
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* Detect CI environment from process.env.
|
|
5
22
|
* @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
|
|
@@ -55,16 +72,12 @@ export function formatGitHubAnnotations(report) {
|
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
// Gate annotations
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
lines.push(`::notice title=Gate ${gateName}::satisfied`);
|
|
65
|
-
} else {
|
|
66
|
-
lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
|
|
67
|
-
}
|
|
75
|
+
for (const [gateName, gateStatus] of normalizeGateEntries(report.subject?.run?.gate_summary)) {
|
|
76
|
+
const normalizedStatus = gateStatus.toLowerCase();
|
|
77
|
+
if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
|
|
78
|
+
lines.push(`::notice title=Gate ${gateName}::satisfied`);
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -113,7 +126,6 @@ export function writeGitHubOutputVars(report, outputPath) {
|
|
|
113
126
|
*/
|
|
114
127
|
export function formatJUnitXml(report) {
|
|
115
128
|
const run = report.subject?.run || {};
|
|
116
|
-
const gates = run.gate_summary || {};
|
|
117
129
|
const turns = run.turns || [];
|
|
118
130
|
|
|
119
131
|
// Escape XML special characters
|
|
@@ -124,7 +136,7 @@ export function formatJUnitXml(report) {
|
|
|
124
136
|
.replace(/"/g, '"');
|
|
125
137
|
|
|
126
138
|
// Build gate test cases
|
|
127
|
-
const gateEntries =
|
|
139
|
+
const gateEntries = normalizeGateEntries(run.gate_summary);
|
|
128
140
|
const gateFailures = gateEntries.filter(([, status]) => {
|
|
129
141
|
const s = status.toLowerCase();
|
|
130
142
|
return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
|
|
@@ -1141,10 +1141,27 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
1143
|
const errorClass = result.error_class || 'reconcile_refused';
|
|
1144
|
+
// RB-14: `reconcile-state --accept-operator-head` runs the SAME safety check, so it
|
|
1145
|
+
// cannot clear an unsafe-edit refusal — recommending it for governance_state_modified
|
|
1146
|
+
// or critical_artifact_deleted is a dead end. Give the recovery that actually applies.
|
|
1147
|
+
const offendingCommit = result.offending_commit || result.commit || null;
|
|
1148
|
+
const offendingPath = result.offending_path || result.path || null;
|
|
1149
|
+
let recoveryAction;
|
|
1150
|
+
let recoveryDetailTail;
|
|
1151
|
+
if (errorClass === 'governance_state_modified') {
|
|
1152
|
+
recoveryAction = offendingCommit ? `git revert ${offendingCommit.slice(0, 12)}` : 'git revert <offending-commit>';
|
|
1153
|
+
recoveryDetailTail = `${offendingPath || 'A commit'} edits governed state under .agentxchain/ directly, which the anti-tamper guard never auto-accepts (reconcile-state --accept-operator-head enforces the same check). Revert it (${recoveryAction}) to restore the governed lineage, or restart from an explicit prior checkpoint.`;
|
|
1154
|
+
} else if (errorClass === 'critical_artifact_deleted') {
|
|
1155
|
+
recoveryAction = (offendingCommit && offendingPath) ? `git checkout ${offendingCommit.slice(0, 12)}^ -- ${offendingPath}` : 'git checkout <commit>^ -- <deleted-path>';
|
|
1156
|
+
recoveryDetailTail = `Critical governed evidence ${offendingPath || ''} was deleted; restore it (${recoveryAction}), then resume.`;
|
|
1157
|
+
} else {
|
|
1158
|
+
recoveryAction = 'agentxchain reconcile-state --accept-operator-head';
|
|
1159
|
+
recoveryDetailTail = `Run: ${recoveryAction} once the unsafe changes are resolved, or revert them.`;
|
|
1160
|
+
}
|
|
1144
1161
|
const detailLines = [
|
|
1145
1162
|
`Operator-commit auto-reconcile refused (${errorClass}).`,
|
|
1146
1163
|
result.error || 'Unsafe operator commits detected; manual recovery required.',
|
|
1147
|
-
|
|
1164
|
+
recoveryDetailTail,
|
|
1148
1165
|
];
|
|
1149
1166
|
const detail = detailLines.join(' ');
|
|
1150
1167
|
|
|
@@ -1164,7 +1181,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1164
1181
|
...((state.blocked_reason || {}).recovery || {}),
|
|
1165
1182
|
typed_reason: 'operator_commit_reconcile_refused',
|
|
1166
1183
|
owner: 'human',
|
|
1167
|
-
recovery_action:
|
|
1184
|
+
recovery_action: recoveryAction,
|
|
1168
1185
|
turn_retained: false,
|
|
1169
1186
|
detail,
|
|
1170
1187
|
},
|
|
@@ -1196,7 +1213,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
|
|
|
1196
1213
|
status: 'blocked',
|
|
1197
1214
|
action: 'operator_commit_reconcile_refused',
|
|
1198
1215
|
run_id: session.current_run_id,
|
|
1199
|
-
recovery_action:
|
|
1216
|
+
recovery_action: recoveryAction,
|
|
1200
1217
|
blocked_category: 'operator_commit_reconcile_refused',
|
|
1201
1218
|
error_class: errorClass,
|
|
1202
1219
|
};
|
|
@@ -2579,10 +2596,47 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2579
2596
|
process.on('SIGINT', sigHandler);
|
|
2580
2597
|
|
|
2581
2598
|
try {
|
|
2599
|
+
// RB-13: circuit-breaker for the recover-and-continue path. A deterministic,
|
|
2600
|
+
// recurring failure (e.g. an unclean baseline) must escalate, not busy-loop
|
|
2601
|
+
// forever — without this, the same start error spun 582 times in seconds.
|
|
2602
|
+
// Track consecutive identical recoveries; halt and escalate after a cap, and
|
|
2603
|
+
// back off between attempts so even allowed retries can't tight-loop.
|
|
2604
|
+
let consecutiveRecoveries = 0;
|
|
2605
|
+
let lastRecoveryReason = null;
|
|
2606
|
+
const maxConsecutiveRecoveries = Math.max(1, contOpts.maxConsecutiveRecoveries ?? 3);
|
|
2607
|
+
|
|
2582
2608
|
while (!stopping) {
|
|
2583
2609
|
const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
|
|
2584
2610
|
|
|
2585
2611
|
if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
|
|
2612
|
+
const recoveryReason = step.stop_reason || step.action || 'unknown';
|
|
2613
|
+
if (recoveryReason === lastRecoveryReason) {
|
|
2614
|
+
consecutiveRecoveries += 1;
|
|
2615
|
+
} else {
|
|
2616
|
+
consecutiveRecoveries = 1;
|
|
2617
|
+
lastRecoveryReason = recoveryReason;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
if (consecutiveRecoveries >= maxConsecutiveRecoveries) {
|
|
2621
|
+
// Circuit open: the same recoverable failure keeps recurring without
|
|
2622
|
+
// progress. Escalate to a human instead of spinning the loop.
|
|
2623
|
+
session.status = 'blocked';
|
|
2624
|
+
writeContinuousSession(root, session);
|
|
2625
|
+
emitRunEvent(root, 'continuous_recovery_circuit_open', {
|
|
2626
|
+
run_id: session.current_run_id || null,
|
|
2627
|
+
phase: null,
|
|
2628
|
+
status: 'blocked',
|
|
2629
|
+
payload: {
|
|
2630
|
+
session_id: session.session_id,
|
|
2631
|
+
failed_action: step.action || null,
|
|
2632
|
+
failed_reason: recoveryReason,
|
|
2633
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2634
|
+
},
|
|
2635
|
+
});
|
|
2636
|
+
log(`Continuous loop halted (circuit-breaker): the same recoverable failure recurred ${consecutiveRecoveries} times with no progress — escalating to a human instead of spinning. Reason: ${recoveryReason}`);
|
|
2637
|
+
return { exitCode: 1, session };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2586
2640
|
session.status = 'running';
|
|
2587
2641
|
writeContinuousSession(root, session);
|
|
2588
2642
|
emitRunEvent(root, 'session_failed_recovered_active_run', {
|
|
@@ -2593,12 +2647,20 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2593
2647
|
session_id: session.session_id,
|
|
2594
2648
|
failed_action: step.action || null,
|
|
2595
2649
|
failed_reason: step.stop_reason || null,
|
|
2650
|
+
consecutive_recoveries: consecutiveRecoveries,
|
|
2596
2651
|
},
|
|
2597
2652
|
});
|
|
2598
|
-
log(
|
|
2653
|
+
log(`Session failure recovered - governed run still active, continuing (recovery ${consecutiveRecoveries}/${maxConsecutiveRecoveries}).`);
|
|
2654
|
+
// Backoff before retrying so a recurring failure cannot busy-loop.
|
|
2655
|
+
const recoveryBackoffMs = Math.max(1000, (contOpts.cooldownSeconds ?? 5) * 1000);
|
|
2656
|
+
await new Promise((resolve) => setTimeout(resolve, recoveryBackoffMs));
|
|
2599
2657
|
continue;
|
|
2600
2658
|
}
|
|
2601
2659
|
|
|
2660
|
+
// A non-recovery step means the loop made forward progress — reset the breaker.
|
|
2661
|
+
consecutiveRecoveries = 0;
|
|
2662
|
+
lastRecoveryReason = null;
|
|
2663
|
+
|
|
2602
2664
|
// Terminal states
|
|
2603
2665
|
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
|
|
2604
2666
|
const terminalMessage = describeContinuousTerminalStep(step, contOpts);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-JSON cost parser — extracts usage/cost metadata from Claude Code's
|
|
3
|
+
* stream-json NDJSON stdout output.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code with `--output-format stream-json --verbose` emits newline-
|
|
6
|
+
* delimited JSON events. The final `result` event contains a `usage` object
|
|
7
|
+
* with `input_tokens`, `output_tokens`, and optionally cache token counts,
|
|
8
|
+
* plus a top-level `cost_usd` and `model` field.
|
|
9
|
+
*
|
|
10
|
+
* This parser accumulates stdout chunks, splits by newline, parses each
|
|
11
|
+
* complete line as JSON, and stores the last `result` event's cost data.
|
|
12
|
+
*
|
|
13
|
+
* Resilience guarantees:
|
|
14
|
+
* - Partial JSON lines are buffered until the next newline arrives.
|
|
15
|
+
* - Non-JSON lines are silently skipped (no throw, no error log).
|
|
16
|
+
* - Multiple `result` events: last one wins.
|
|
17
|
+
* - Missing `usage` in a `result` event: getResult() returns null.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Claude-specific bundled cost rates (USD per million tokens).
|
|
21
|
+
// Duplicated from api-proxy-adapter.js for this module's use only — DEC-004
|
|
22
|
+
// defers extraction to a shared module.
|
|
23
|
+
const CLAUDE_COST_RATES = {
|
|
24
|
+
'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
|
|
25
|
+
'claude-opus-4-6': { input_per_1m: 5.00, output_per_1m: 25.00 },
|
|
26
|
+
'claude-haiku-4-5-20251001': { input_per_1m: 1.00, output_per_1m: 5.00 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve cost rates for a model. Checks operator-supplied overrides first,
|
|
31
|
+
* then falls back to the bundled Claude rates.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} model
|
|
34
|
+
* @param {object} [config]
|
|
35
|
+
* @returns {{ input_per_1m: number, output_per_1m: number } | null}
|
|
36
|
+
*/
|
|
37
|
+
export function getClaudeCostRates(model, config) {
|
|
38
|
+
const operatorRates = config?.budget?.cost_rates;
|
|
39
|
+
if (operatorRates && typeof operatorRates === 'object' && operatorRates[model]) {
|
|
40
|
+
const r = operatorRates[model];
|
|
41
|
+
if (Number.isFinite(r.input_per_1m) && Number.isFinite(r.output_per_1m)) {
|
|
42
|
+
return r;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return CLAUDE_COST_RATES[model] || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} StreamJsonCostResult
|
|
50
|
+
* @property {number} input_tokens
|
|
51
|
+
* @property {number} output_tokens
|
|
52
|
+
* @property {number|null} cost_usd - Claude Code's self-reported cost (if present)
|
|
53
|
+
* @property {string|null} model - Model identifier for rate lookup
|
|
54
|
+
* @property {number|null} cache_creation_input_tokens
|
|
55
|
+
* @property {number|null} cache_read_input_tokens
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a stateful parser that accumulates stream-json chunks and extracts
|
|
60
|
+
* the final result event containing usage metadata.
|
|
61
|
+
*
|
|
62
|
+
* @returns {{ push(chunk: string): void, getResult(): StreamJsonCostResult | null }}
|
|
63
|
+
*/
|
|
64
|
+
export function createStreamJsonCostParser() {
|
|
65
|
+
let buffer = '';
|
|
66
|
+
/** @type {StreamJsonCostResult | null} */
|
|
67
|
+
let lastResult = null;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
/**
|
|
71
|
+
* Feed a stdout chunk into the parser.
|
|
72
|
+
* @param {string} chunk
|
|
73
|
+
*/
|
|
74
|
+
push(chunk) {
|
|
75
|
+
buffer += chunk;
|
|
76
|
+
const lines = buffer.split('\n');
|
|
77
|
+
// Last element is either empty (line ended with \n) or a partial line
|
|
78
|
+
buffer = lines.pop() || '';
|
|
79
|
+
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed) continue;
|
|
83
|
+
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(trimmed);
|
|
87
|
+
} catch {
|
|
88
|
+
// Non-JSON line (tool output, diagnostic text) — skip silently
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
parsed &&
|
|
94
|
+
typeof parsed === 'object' &&
|
|
95
|
+
parsed.type === 'result' &&
|
|
96
|
+
parsed.usage &&
|
|
97
|
+
typeof parsed.usage === 'object' &&
|
|
98
|
+
Number.isFinite(parsed.usage.input_tokens) &&
|
|
99
|
+
Number.isFinite(parsed.usage.output_tokens)
|
|
100
|
+
) {
|
|
101
|
+
lastResult = {
|
|
102
|
+
input_tokens: parsed.usage.input_tokens,
|
|
103
|
+
output_tokens: parsed.usage.output_tokens,
|
|
104
|
+
cost_usd: typeof parsed.cost_usd === 'number' && Number.isFinite(parsed.cost_usd)
|
|
105
|
+
? parsed.cost_usd
|
|
106
|
+
: null,
|
|
107
|
+
model: typeof parsed.model === 'string' ? parsed.model : null,
|
|
108
|
+
cache_creation_input_tokens: Number.isFinite(parsed.usage.cache_creation_input_tokens)
|
|
109
|
+
? parsed.usage.cache_creation_input_tokens
|
|
110
|
+
: null,
|
|
111
|
+
cache_read_input_tokens: Number.isFinite(parsed.usage.cache_read_input_tokens)
|
|
112
|
+
? parsed.usage.cache_read_input_tokens
|
|
113
|
+
: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return the extracted cost data, or null if no valid result event was found.
|
|
121
|
+
* @returns {StreamJsonCostResult | null}
|
|
122
|
+
*/
|
|
123
|
+
getResult() {
|
|
124
|
+
return lastResult;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a cost object from parsed stream-json data for turn-result enrichment.
|
|
131
|
+
*
|
|
132
|
+
* @param {StreamJsonCostResult} parsedCost
|
|
133
|
+
* @param {object} [config] - normalized config (for operator cost_rates overrides)
|
|
134
|
+
* @returns {{ input_tokens: number, output_tokens: number, usd: number, source: string, model?: string, cache_creation_input_tokens?: number, cache_read_input_tokens?: number }}
|
|
135
|
+
*/
|
|
136
|
+
export function buildCostFromStreamJson(parsedCost, config) {
|
|
137
|
+
const cost = {
|
|
138
|
+
input_tokens: parsedCost.input_tokens,
|
|
139
|
+
output_tokens: parsedCost.output_tokens,
|
|
140
|
+
source: 'stream_json',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Prefer Claude Code's own cost_usd when available
|
|
144
|
+
if (typeof parsedCost.cost_usd === 'number' && Number.isFinite(parsedCost.cost_usd)) {
|
|
145
|
+
cost.usd = Math.round(parsedCost.cost_usd * 1000) / 1000;
|
|
146
|
+
} else if (parsedCost.model) {
|
|
147
|
+
const rates = getClaudeCostRates(parsedCost.model, config);
|
|
148
|
+
if (rates) {
|
|
149
|
+
cost.usd = Math.round(
|
|
150
|
+
((parsedCost.input_tokens / 1_000_000) * rates.input_per_1m +
|
|
151
|
+
(parsedCost.output_tokens / 1_000_000) * rates.output_per_1m) * 1000
|
|
152
|
+
) / 1000;
|
|
153
|
+
} else {
|
|
154
|
+
cost.usd = 0;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
cost.usd = 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (parsedCost.model) cost.model = parsedCost.model;
|
|
161
|
+
if (parsedCost.cache_creation_input_tokens != null) {
|
|
162
|
+
cost.cache_creation_input_tokens = parsedCost.cache_creation_input_tokens;
|
|
163
|
+
}
|
|
164
|
+
if (parsedCost.cache_read_input_tokens != null) {
|
|
165
|
+
cost.cache_read_input_tokens = parsedCost.cache_read_input_tokens;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return cost;
|
|
169
|
+
}
|
|
@@ -737,6 +737,16 @@ function validateArtifact(tr, config, state = null) {
|
|
|
737
737
|
`Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
|
|
738
738
|
'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
|
|
739
739
|
);
|
|
740
|
+
} else if (productFiles.every(f => isTestPath(f))) {
|
|
741
|
+
// Work-substance signal: an implementation turn that changed ONLY test files produced
|
|
742
|
+
// no implementation source. That is legitimate for acceptance/verification objectives,
|
|
743
|
+
// but thin for an "implement X" objective — surface it for QA/operator review instead
|
|
744
|
+
// of silently accepting (this test-only pattern slipped through QA during dogfooding).
|
|
745
|
+
warnings.push(
|
|
746
|
+
`Role "${tr.role}" completed an implementation turn that changed only test files ` +
|
|
747
|
+
`(${productFiles.join(', ')}) with no implementation source. This is valid for acceptance or ` +
|
|
748
|
+
'verification work; if the objective was to implement behavior, review whether the turn is test-only before accepting.'
|
|
749
|
+
);
|
|
740
750
|
}
|
|
741
751
|
}
|
|
742
752
|
|
|
@@ -792,6 +802,16 @@ function isProductChangePath(filePath) {
|
|
|
792
802
|
&& !filePath.startsWith('.agentxchain/staging/');
|
|
793
803
|
}
|
|
794
804
|
|
|
805
|
+
// A test/spec file path — used to flag implementation turns that produced only tests
|
|
806
|
+
// (no implementation source) so test-only work is reviewed rather than silently accepted.
|
|
807
|
+
function isTestPath(filePath) {
|
|
808
|
+
if (typeof filePath !== 'string') {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
return /(^|\/)(tests?|__tests__|spec)\//.test(filePath)
|
|
812
|
+
|| /\.(test|spec)\.[cm]?[jt]sx?$/.test(filePath);
|
|
813
|
+
}
|
|
814
|
+
|
|
795
815
|
function hasCheckpointableProducedFiles(tr) {
|
|
796
816
|
return Array.isArray(tr.verification?.produced_files)
|
|
797
817
|
&& tr.verification.produced_files.some((entry) => (
|