agentxchain 0.8.8 → 2.2.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.
Files changed (74) hide show
  1. package/README.md +136 -136
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +858 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,1076 @@
1
+ /**
2
+ * API proxy adapter — review-only synchronous provider calls.
3
+ *
4
+ * v1 scope (Session #19 freeze):
5
+ * - review_only roles only
6
+ * - single request / single response (synchronous within `step`)
7
+ * - no tool use, no patch application, no repo writes
8
+ * - turn result must arrive as structured JSON
9
+ * - provider telemetry is authoritative for cost
10
+ *
11
+ * The adapter:
12
+ * 1. Reads the rendered dispatch bundle (PROMPT.md + CONTEXT.md)
13
+ * 2. Sends a single API request to the configured provider
14
+ * 3. Persists raw request/response metadata for auditability
15
+ * 4. Extracts structured turn result JSON from the response
16
+ * 5. Stages it at .agentxchain/staging/turn-result.json
17
+ *
18
+ * Error classification (Turn 13 — API_PROXY_ERROR_RECOVERY_SPEC):
19
+ * All error returns include a `classified` ApiProxyError object with
20
+ * error_class, recovery instructions, and retryable flag.
21
+ *
22
+ * Supported providers: "anthropic" (others can be added behind the same interface)
23
+ */
24
+
25
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { evaluateTokenBudget, SYSTEM_PROMPT, SEPARATOR } from '../token-budget.js';
28
+ import {
29
+ getDispatchApiRequestPath,
30
+ getDispatchContextPath,
31
+ getDispatchEffectiveContextPath,
32
+ getDispatchPromptPath,
33
+ getDispatchTokenBudgetPath,
34
+ getDispatchTurnDir,
35
+ getTurnApiErrorPath,
36
+ getTurnProviderResponsePath,
37
+ getTurnRetryTracePath,
38
+ getTurnStagingDir,
39
+ getTurnStagingResultPath,
40
+ } from '../turn-paths.js';
41
+ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
42
+
43
+ // Provider endpoint registry
44
+ const PROVIDER_ENDPOINTS = {
45
+ anthropic: 'https://api.anthropic.com/v1/messages',
46
+ };
47
+
48
+ // Cost rates per million tokens (USD)
49
+ const COST_RATES = {
50
+ 'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
51
+ 'claude-opus-4-6': { input_per_1m: 15.00, output_per_1m: 75.00 },
52
+ 'claude-haiku-4-5-20251001': { input_per_1m: 0.80, output_per_1m: 4.00 },
53
+ };
54
+
55
+ const RETRYABLE_ERROR_CLASSES = [
56
+ 'rate_limited',
57
+ 'network_failure',
58
+ 'timeout',
59
+ 'response_parse_failure',
60
+ 'turn_result_extraction_failure',
61
+ 'unknown_api_error',
62
+ 'provider_overloaded',
63
+ ];
64
+
65
+ const DEFAULT_RETRY_POLICY = {
66
+ max_attempts: 3,
67
+ base_delay_ms: 1000,
68
+ max_delay_ms: 8000,
69
+ backoff_multiplier: 2,
70
+ jitter: 'full',
71
+ retry_on: RETRYABLE_ERROR_CLASSES,
72
+ };
73
+
74
+ const PROVIDER_ERROR_MAPS = {
75
+ anthropic: {
76
+ extractErrorType(body) {
77
+ return typeof body?.error?.type === 'string' ? body.error.type : null;
78
+ },
79
+ extractErrorCode(body) {
80
+ return typeof body?.error?.code === 'string' ? body.error.code : null;
81
+ },
82
+ mappings: [
83
+ { provider_error_type: 'authentication_error', http_status: 401, error_class: 'auth_failure', retryable: false },
84
+ { provider_error_type: 'permission_error', http_status: 403, error_class: 'auth_failure', retryable: false },
85
+ { provider_error_type: 'not_found_error', http_status: 404, error_class: 'model_not_found', retryable: false },
86
+ { provider_error_type: 'overloaded_error', http_status: 529, error_class: 'provider_overloaded', retryable: true },
87
+ { provider_error_type: 'rate_limit_error', http_status: 429, body_pattern: /daily|spend|budget/i, error_class: 'rate_limited', retryable: false },
88
+ { provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
89
+ { provider_error_type: 'invalid_request_error', http_status: 400, body_pattern: /context|token.*limit|too.many.tokens/i, error_class: 'context_overflow', retryable: false },
90
+ { provider_error_type: 'invalid_request_error', http_status: 400, error_class: 'invalid_request', retryable: false },
91
+ { provider_error_type: 'api_error', http_status: 500, error_class: 'unknown_api_error', retryable: true },
92
+ ],
93
+ },
94
+ };
95
+
96
+ // ── Error classification ──────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Build a classified ApiProxyError object.
100
+ */
101
+ function classifyError(
102
+ errorClass,
103
+ message,
104
+ recovery,
105
+ retryable,
106
+ httpStatus,
107
+ rawDetail,
108
+ providerErrorType = null,
109
+ providerErrorCode = null
110
+ ) {
111
+ return {
112
+ error_class: errorClass,
113
+ message,
114
+ recovery,
115
+ retryable,
116
+ http_status: httpStatus ?? null,
117
+ raw_detail: rawDetail ? String(rawDetail).slice(0, 500) : null,
118
+ provider_error_type: providerErrorType,
119
+ provider_error_code: providerErrorCode,
120
+ };
121
+ }
122
+
123
+ function tryParseJson(value) {
124
+ if (typeof value !== 'string' || !value.trim()) return null;
125
+ try {
126
+ return JSON.parse(value);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function httpStatusMatches(ruleStatus, actualStatus) {
133
+ if (Array.isArray(ruleStatus)) {
134
+ return ruleStatus.includes(actualStatus);
135
+ }
136
+ if (typeof ruleStatus === 'number') {
137
+ return ruleStatus === actualStatus;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ function buildMappedProviderError(mapping, context) {
143
+ const {
144
+ provider,
145
+ model,
146
+ authEnv,
147
+ status,
148
+ rawDetail,
149
+ providerErrorType,
150
+ providerErrorCode,
151
+ } = context;
152
+
153
+ switch (mapping.error_class) {
154
+ case 'auth_failure':
155
+ return classifyError(
156
+ 'auth_failure',
157
+ `API authentication failed (${status})`,
158
+ `Check that "${authEnv}" contains a valid API key for ${provider}`,
159
+ mapping.retryable ?? false,
160
+ status,
161
+ rawDetail,
162
+ providerErrorType,
163
+ providerErrorCode
164
+ );
165
+ case 'model_not_found':
166
+ return classifyError(
167
+ 'model_not_found',
168
+ `Model "${model}" not found (${status})`,
169
+ `Model "${model}" not found. Check runtime config model name.`,
170
+ mapping.retryable ?? false,
171
+ status,
172
+ rawDetail,
173
+ providerErrorType,
174
+ providerErrorCode
175
+ );
176
+ case 'provider_overloaded':
177
+ return classifyError(
178
+ 'provider_overloaded',
179
+ `${provider} is temporarily overloaded`,
180
+ `${provider} is overloaded. Retry with backoff: agentxchain step --resume`,
181
+ mapping.retryable ?? true,
182
+ status,
183
+ rawDetail,
184
+ providerErrorType,
185
+ providerErrorCode
186
+ );
187
+ case 'rate_limited':
188
+ if (mapping.retryable === false) {
189
+ return classifyError(
190
+ 'rate_limited',
191
+ `Provider spend limit reached at ${provider}`,
192
+ `${provider} rejected the request due to a spend or budget limit. Increase provider budget or wait for reset before retrying.`,
193
+ false,
194
+ status,
195
+ rawDetail,
196
+ providerErrorType,
197
+ providerErrorCode
198
+ );
199
+ }
200
+ return classifyError(
201
+ 'rate_limited',
202
+ `Rate limited by ${provider}`,
203
+ `Rate limited by ${provider}. Wait and retry: agentxchain step --resume`,
204
+ true,
205
+ status,
206
+ rawDetail,
207
+ providerErrorType,
208
+ providerErrorCode
209
+ );
210
+ case 'context_overflow':
211
+ return classifyError(
212
+ 'context_overflow',
213
+ 'Prompt exceeds model context window',
214
+ 'Prompt exceeds model context window. Reduce context or switch to a larger model.',
215
+ false,
216
+ status,
217
+ rawDetail,
218
+ providerErrorType,
219
+ providerErrorCode
220
+ );
221
+ case 'invalid_request':
222
+ return classifyError(
223
+ 'invalid_request',
224
+ `API request rejected by ${provider}`,
225
+ 'Provider rejected the request as invalid. Fix the prompt, parameters, or adapter request shape before retrying.',
226
+ false,
227
+ status,
228
+ rawDetail,
229
+ providerErrorType,
230
+ providerErrorCode
231
+ );
232
+ default:
233
+ return classifyError(
234
+ mapping.error_class,
235
+ `API returned ${status}`,
236
+ `API returned ${status}. Review error detail and retry or complete manually.`,
237
+ mapping.retryable ?? true,
238
+ status,
239
+ rawDetail,
240
+ providerErrorType,
241
+ providerErrorCode
242
+ );
243
+ }
244
+ }
245
+
246
+ function classifyProviderHttpError(status, body, provider, model, authEnv) {
247
+ const providerMap = PROVIDER_ERROR_MAPS[provider];
248
+ if (!providerMap) {
249
+ return null;
250
+ }
251
+
252
+ const parsedBody = tryParseJson(body);
253
+ if (!parsedBody) {
254
+ return null;
255
+ }
256
+
257
+ const providerErrorType = providerMap.extractErrorType(parsedBody);
258
+ const providerErrorCode = providerMap.extractErrorCode(parsedBody);
259
+ if (!providerErrorType) {
260
+ return { matched: null, providerErrorType: null, providerErrorCode };
261
+ }
262
+
263
+ for (const mapping of providerMap.mappings) {
264
+ if (mapping.provider_error_type !== providerErrorType) continue;
265
+ if (!httpStatusMatches(mapping.http_status, status)) continue;
266
+ if (mapping.body_pattern && !mapping.body_pattern.test(body)) continue;
267
+ return {
268
+ matched: buildMappedProviderError(mapping, {
269
+ provider,
270
+ model,
271
+ authEnv,
272
+ status,
273
+ rawDetail: body,
274
+ providerErrorType,
275
+ providerErrorCode,
276
+ }),
277
+ providerErrorType,
278
+ providerErrorCode,
279
+ };
280
+ }
281
+
282
+ return { matched: null, providerErrorType, providerErrorCode };
283
+ }
284
+
285
+ /**
286
+ * Classify an HTTP error response into a typed ApiProxyError.
287
+ */
288
+ function classifyHttpError(status, body, provider, model, authEnv) {
289
+ const providerClassification = classifyProviderHttpError(status, body, provider, model, authEnv);
290
+ if (providerClassification?.matched) {
291
+ return providerClassification.matched;
292
+ }
293
+ const providerErrorType = providerClassification?.providerErrorType ?? null;
294
+ const providerErrorCode = providerClassification?.providerErrorCode ?? null;
295
+
296
+ if (status === 401 || status === 403) {
297
+ return classifyError(
298
+ 'auth_failure',
299
+ `API authentication failed (${status})`,
300
+ `Check that "${authEnv}" contains a valid API key for ${provider}`,
301
+ false, status, body, providerErrorType, providerErrorCode
302
+ );
303
+ }
304
+
305
+ if (status === 429) {
306
+ return classifyError(
307
+ 'rate_limited',
308
+ `Rate limited by ${provider}`,
309
+ `Rate limited by ${provider}. Wait and retry: agentxchain step --resume`,
310
+ true, status, body, providerErrorType, providerErrorCode
311
+ );
312
+ }
313
+
314
+ if (status === 404) {
315
+ return classifyError(
316
+ 'model_not_found',
317
+ `Model "${model}" not found (404)`,
318
+ `Model "${model}" not found. Check runtime config model name.`,
319
+ false, status, body, providerErrorType, providerErrorCode
320
+ );
321
+ }
322
+
323
+ if (status === 400) {
324
+ const lowerBody = (body || '').toLowerCase();
325
+ if (lowerBody.includes('context') || lowerBody.includes('token')) {
326
+ return classifyError(
327
+ 'context_overflow',
328
+ 'Prompt exceeds model context window',
329
+ 'Prompt exceeds model context window. Reduce context or switch to a larger model.',
330
+ false, status, body, providerErrorType, providerErrorCode
331
+ );
332
+ }
333
+ }
334
+
335
+ return classifyError(
336
+ 'unknown_api_error',
337
+ `API returned ${status}`,
338
+ `API returned ${status}. Review error detail and retry or complete manually.`,
339
+ true, status, body, providerErrorType, providerErrorCode
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Persist classified error to staging for auditability (best-effort).
345
+ */
346
+ function persistApiError(root, turnId, classified) {
347
+ try {
348
+ const stagingDir = join(root, getTurnStagingDir(turnId));
349
+ mkdirSync(stagingDir, { recursive: true });
350
+ writeFileSync(
351
+ join(root, getTurnApiErrorPath(turnId)),
352
+ JSON.stringify(classified, null, 2) + '\n'
353
+ );
354
+ } catch {
355
+ // best-effort audit artifact
356
+ }
357
+ }
358
+
359
+ function clearApiError(root, turnId) {
360
+ try {
361
+ rmSync(join(root, getTurnApiErrorPath(turnId)), { force: true });
362
+ } catch {
363
+ // best-effort cleanup
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Persist retry trace artifact for auditability (best-effort).
369
+ */
370
+ function persistRetryTrace(root, turnId, trace) {
371
+ try {
372
+ const stagingDir = join(root, getTurnStagingDir(turnId));
373
+ mkdirSync(stagingDir, { recursive: true });
374
+ const tracePath = join(root, getTurnRetryTracePath(turnId));
375
+ writeFileSync(tracePath, JSON.stringify(trace, null, 2) + '\n');
376
+ return tracePath;
377
+ } catch {
378
+ // best-effort audit artifact
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function emptyUsageTotals() {
384
+ return {
385
+ input_tokens: 0,
386
+ output_tokens: 0,
387
+ usd: 0,
388
+ };
389
+ }
390
+
391
+ function usageFromTelemetry(model, usage) {
392
+ if (!usage || typeof usage !== 'object') return null;
393
+
394
+ const inputTokens = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
395
+ const outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
396
+ const rates = COST_RATES[model];
397
+ const usd = rates
398
+ ? (inputTokens / 1_000_000) * rates.input_per_1m + (outputTokens / 1_000_000) * rates.output_per_1m
399
+ : 0;
400
+
401
+ return {
402
+ input_tokens: inputTokens,
403
+ output_tokens: outputTokens,
404
+ usd: Math.round(usd * 1000) / 1000,
405
+ };
406
+ }
407
+
408
+ function addUsageTotals(total, usage) {
409
+ if (!usage) return total;
410
+ return {
411
+ input_tokens: total.input_tokens + (usage.input_tokens || 0),
412
+ output_tokens: total.output_tokens + (usage.output_tokens || 0),
413
+ usd: Math.round((total.usd + (usage.usd || 0)) * 1000) / 1000,
414
+ };
415
+ }
416
+
417
+ function writeRetryTrace(root, turnId, provider, model, state, runtimeId, retryPolicy, attemptsMade, finalOutcome, aggregateUsage, attempts) {
418
+ const trace = {
419
+ provider,
420
+ model,
421
+ run_id: state.run_id,
422
+ turn_id: turnId,
423
+ runtime_id: runtimeId,
424
+ max_attempts: retryPolicy?.max_attempts ?? 1,
425
+ attempts_made: attemptsMade,
426
+ final_outcome: finalOutcome,
427
+ aggregate_usage: { ...aggregateUsage },
428
+ attempts,
429
+ };
430
+ return persistRetryTrace(root, turnId, trace);
431
+ }
432
+
433
+ function persistPreflightArtifacts(root, turnId, effectiveContext, report) {
434
+ try {
435
+ const dispatchDir = join(root, getDispatchTurnDir(turnId));
436
+ mkdirSync(dispatchDir, { recursive: true });
437
+ writeFileSync(join(root, getDispatchEffectiveContextPath(turnId)), effectiveContext);
438
+ writeFileSync(join(root, getDispatchTokenBudgetPath(turnId)), JSON.stringify(report, null, 2) + '\n');
439
+ } catch {
440
+ // best-effort audit artifacts
441
+ }
442
+ }
443
+
444
+ function resolveRetryPolicy(runtime) {
445
+ const retryPolicy = runtime?.retry_policy;
446
+ if (!retryPolicy || retryPolicy.enabled !== true) {
447
+ return null;
448
+ }
449
+
450
+ return {
451
+ enabled: true,
452
+ max_attempts: retryPolicy.max_attempts ?? DEFAULT_RETRY_POLICY.max_attempts,
453
+ base_delay_ms: retryPolicy.base_delay_ms ?? DEFAULT_RETRY_POLICY.base_delay_ms,
454
+ max_delay_ms: retryPolicy.max_delay_ms ?? DEFAULT_RETRY_POLICY.max_delay_ms,
455
+ backoff_multiplier: retryPolicy.backoff_multiplier ?? DEFAULT_RETRY_POLICY.backoff_multiplier,
456
+ jitter: retryPolicy.jitter ?? DEFAULT_RETRY_POLICY.jitter,
457
+ retry_on: Array.isArray(retryPolicy.retry_on)
458
+ ? retryPolicy.retry_on
459
+ : DEFAULT_RETRY_POLICY.retry_on,
460
+ };
461
+ }
462
+
463
+ function resolvePreflightTokenization(runtime) {
464
+ const preflight = runtime?.preflight_tokenization;
465
+ if (!preflight || preflight.enabled !== true) {
466
+ return null;
467
+ }
468
+
469
+ return {
470
+ enabled: true,
471
+ tokenizer: preflight.tokenizer ?? 'provider_local',
472
+ safety_margin_tokens: preflight.safety_margin_tokens ?? 2048,
473
+ context_window_tokens: runtime.context_window_tokens,
474
+ };
475
+ }
476
+
477
+ function shouldRetryAttempt(classified, retryPolicy, attemptNumber) {
478
+ if (!retryPolicy || !classified?.retryable) return false;
479
+ if (attemptNumber >= retryPolicy.max_attempts) return false;
480
+ if (!Array.isArray(retryPolicy.retry_on)) return true;
481
+ return retryPolicy.retry_on.includes(classified.error_class);
482
+ }
483
+
484
+ function calculateRetryDelayMs(retryPolicy, nextAttemptNumber) {
485
+ const rawDelayMs = Math.min(
486
+ retryPolicy.max_delay_ms,
487
+ retryPolicy.base_delay_ms * retryPolicy.backoff_multiplier ** (nextAttemptNumber - 2)
488
+ );
489
+
490
+ if (retryPolicy.jitter === 'none') {
491
+ return rawDelayMs;
492
+ }
493
+
494
+ return Math.floor(Math.random() * (rawDelayMs + 1));
495
+ }
496
+
497
+ function waitForRetryDelay(delayMs, signal) {
498
+ if (delayMs <= 0) {
499
+ if (signal?.aborted) {
500
+ return Promise.reject(new Error('aborted'));
501
+ }
502
+ return Promise.resolve();
503
+ }
504
+
505
+ return new Promise((resolve, reject) => {
506
+ if (signal?.aborted) {
507
+ reject(new Error('aborted'));
508
+ return;
509
+ }
510
+
511
+ const timeoutId = setTimeout(() => {
512
+ cleanup();
513
+ resolve();
514
+ }, delayMs);
515
+
516
+ const onAbort = () => {
517
+ clearTimeout(timeoutId);
518
+ cleanup();
519
+ reject(new Error('aborted'));
520
+ };
521
+
522
+ const cleanup = () => {
523
+ if (signal) {
524
+ signal.removeEventListener('abort', onAbort);
525
+ }
526
+ };
527
+
528
+ if (signal) {
529
+ signal.addEventListener('abort', onAbort, { once: true });
530
+ }
531
+ });
532
+ }
533
+
534
+ async function executeApiCall({
535
+ endpoint,
536
+ apiKey,
537
+ provider,
538
+ model,
539
+ authEnv,
540
+ requestBody,
541
+ timeoutSeconds,
542
+ signal,
543
+ }) {
544
+ const timeoutMs = timeoutSeconds * 1000;
545
+ const controller = new AbortController();
546
+ let externalAbort = false;
547
+ let timeoutTriggered = false;
548
+
549
+ const onExternalAbort = () => {
550
+ externalAbort = true;
551
+ controller.abort();
552
+ };
553
+
554
+ if (signal) {
555
+ if (signal.aborted) {
556
+ return { ok: false, aborted: true, error: 'Dispatch aborted by operator' };
557
+ }
558
+ signal.addEventListener('abort', onExternalAbort, { once: true });
559
+ }
560
+
561
+ const timeoutId = setTimeout(() => {
562
+ timeoutTriggered = true;
563
+ controller.abort();
564
+ }, timeoutMs);
565
+
566
+ let response;
567
+ try {
568
+ response = await fetch(endpoint, {
569
+ method: 'POST',
570
+ headers: buildAnthropicHeaders(apiKey),
571
+ body: JSON.stringify(requestBody),
572
+ signal: controller.signal,
573
+ });
574
+ } catch (err) {
575
+ clearTimeout(timeoutId);
576
+ if (signal) {
577
+ signal.removeEventListener('abort', onExternalAbort);
578
+ }
579
+
580
+ if (err.name === 'AbortError') {
581
+ if (externalAbort && !timeoutTriggered) {
582
+ return { ok: false, aborted: true, error: 'Dispatch aborted by operator' };
583
+ }
584
+ return {
585
+ ok: false,
586
+ classified: classifyError(
587
+ 'timeout',
588
+ `Request timed out after ${timeoutSeconds}s`,
589
+ `Request timed out after ${timeoutSeconds}s. Increase timeout_seconds in runtime config or retry: agentxchain step --resume`,
590
+ true, null, null
591
+ ),
592
+ usage: null,
593
+ };
594
+ }
595
+
596
+ return {
597
+ ok: false,
598
+ classified: classifyError(
599
+ 'network_failure',
600
+ `Network error: ${err.message}`,
601
+ `Network error: ${err.message}. Check connectivity and retry: agentxchain step --resume`,
602
+ true, null, err.message
603
+ ),
604
+ usage: null,
605
+ };
606
+ }
607
+
608
+ clearTimeout(timeoutId);
609
+ if (signal) {
610
+ signal.removeEventListener('abort', onExternalAbort);
611
+ }
612
+
613
+ if (!response.ok) {
614
+ let errorBody = '';
615
+ try { errorBody = await response.text(); } catch {}
616
+ return {
617
+ ok: false,
618
+ classified: classifyHttpError(response.status, errorBody, provider, model, authEnv),
619
+ usage: null,
620
+ };
621
+ }
622
+
623
+ let responseData;
624
+ try {
625
+ responseData = await response.json();
626
+ } catch (err) {
627
+ return {
628
+ ok: false,
629
+ classified: classifyError(
630
+ 'response_parse_failure',
631
+ 'Provider returned non-JSON response',
632
+ 'Provider returned non-JSON response. This is usually transient. Retry: agentxchain step --resume',
633
+ true, response.status, err.message
634
+ ),
635
+ usage: null,
636
+ };
637
+ }
638
+
639
+ const usage = usageFromTelemetry(model, responseData.usage);
640
+ const extraction = extractTurnResult(responseData);
641
+
642
+ if (!extraction.ok) {
643
+ return {
644
+ ok: false,
645
+ classified: classifyError(
646
+ 'turn_result_extraction_failure',
647
+ extraction.error,
648
+ 'Model responded but did not produce valid turn result JSON. Retry or complete manually.',
649
+ true, null, null
650
+ ),
651
+ usage,
652
+ responseData,
653
+ };
654
+ }
655
+
656
+ return {
657
+ ok: true,
658
+ responseData,
659
+ turnResult: extraction.turnResult,
660
+ usage,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * Build an error return with classification and audit persistence.
666
+ */
667
+ function errorReturn(root, turnId, classified, extras = {}) {
668
+ persistApiError(root, turnId, classified);
669
+ return { ok: false, error: classified.message, classified, ...extras };
670
+ }
671
+
672
+ // ── Main dispatch ─────────────────────────────────────────────────────────────
673
+
674
+ /**
675
+ * Dispatch a review-only turn via API proxy.
676
+ *
677
+ * @param {string} root - project root directory
678
+ * @param {object} state - current governed state
679
+ * @param {object} config - normalized config
680
+ * @param {object} options - { signal?: AbortSignal, onStatus?: (msg: string) => void, verifyManifest?: boolean, skipManifestVerification?: boolean }
681
+ * @returns {Promise<{ ok: boolean, error?: string, classified?: ApiProxyError, usage?: object, staged?: boolean }>}
682
+ */
683
+ export async function dispatchApiProxy(root, state, config, options = {}) {
684
+ const { signal, onStatus, turnId } = options;
685
+
686
+ const turn = resolveTargetTurn(state, turnId);
687
+ if (!turn) {
688
+ return { ok: false, error: 'No active turn in state' };
689
+ }
690
+
691
+ // Default policy verifies finalized bundles automatically; step.js still
692
+ // passes verifyManifest: true to require a manifest on governed dispatch.
693
+ const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
694
+ if (!manifestCheck.ok) {
695
+ return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}` };
696
+ }
697
+
698
+ const roleId = turn.assigned_role;
699
+ const role = config.roles?.[roleId];
700
+ const runtimeId = turn.runtime_id;
701
+ const runtime = config.runtimes?.[runtimeId];
702
+
703
+ if (!runtime || runtime.type !== 'api_proxy') {
704
+ return { ok: false, error: `Runtime "${runtimeId}" is not an api_proxy runtime` };
705
+ }
706
+
707
+ // Enforce v1 restriction: review_only only
708
+ if (role?.write_authority !== 'review_only') {
709
+ return { ok: false, error: `v1 api_proxy only supports review_only roles (got "${role?.write_authority}")` };
710
+ }
711
+
712
+ // Read dispatch bundle
713
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
714
+ const contextPath = join(root, getDispatchContextPath(turn.turn_id));
715
+
716
+ if (!existsSync(promptPath)) {
717
+ return { ok: false, error: 'Dispatch bundle not found — PROMPT.md missing' };
718
+ }
719
+
720
+ const promptMd = readFileSync(promptPath, 'utf8');
721
+ const contextMd = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
722
+
723
+ // Resolve provider and credentials
724
+ const provider = runtime.provider;
725
+ const model = runtime.model;
726
+ const authEnv = runtime.auth_env;
727
+ const apiKey = process.env[authEnv];
728
+
729
+ if (!apiKey) {
730
+ const classified = classifyError(
731
+ 'missing_credentials',
732
+ `Environment variable "${authEnv}" is not set — required for api_proxy`,
733
+ `Set environment variable "${authEnv}" and retry: agentxchain step --resume`,
734
+ false, null, null
735
+ );
736
+ return errorReturn(root, turn.turn_id, classified);
737
+ }
738
+
739
+ const endpoint = PROVIDER_ENDPOINTS[provider];
740
+ if (!endpoint) {
741
+ const classified = classifyError(
742
+ 'unsupported_provider',
743
+ `Unsupported provider: "${provider}". Supported: ${Object.keys(PROVIDER_ENDPOINTS).join(', ')}`,
744
+ `Provider "${provider}" is not supported. Supported: ${Object.keys(PROVIDER_ENDPOINTS).join(', ')}`,
745
+ false, null, null
746
+ );
747
+ return errorReturn(root, turn.turn_id, classified);
748
+ }
749
+
750
+ // Build request
751
+ const maxOutputTokens = runtime.max_output_tokens || 4096;
752
+ const timeoutSeconds = runtime.timeout_seconds || 120;
753
+ const retryPolicy = resolveRetryPolicy(runtime);
754
+ const preflightTokenization = resolvePreflightTokenization(runtime);
755
+ let effectiveContextMd = contextMd;
756
+
757
+ if (preflightTokenization) {
758
+ const budgetEvaluation = evaluateTokenBudget({
759
+ promptMd,
760
+ contextMd,
761
+ provider,
762
+ model,
763
+ runtimeId,
764
+ runId: state.run_id,
765
+ turnId: turn.turn_id,
766
+ contextWindowTokens: preflightTokenization.context_window_tokens,
767
+ maxOutputTokens,
768
+ safetyMarginTokens: preflightTokenization.safety_margin_tokens,
769
+ });
770
+
771
+ effectiveContextMd = budgetEvaluation.effective_context;
772
+ persistPreflightArtifacts(root, turn.turn_id, effectiveContextMd, budgetEvaluation.report);
773
+
774
+ if (!budgetEvaluation.sent_to_provider) {
775
+ const classified = classifyError(
776
+ 'context_overflow',
777
+ 'Prompt exceeds model context window (detected locally before API call)',
778
+ 'Prompt exceeds model context window. Reduce context or switch to a larger model.',
779
+ false, null,
780
+ 'Local preflight token-budget estimate exceeded available input tokens.'
781
+ );
782
+ return errorReturn(root, turn.turn_id, classified, {
783
+ preflight_artifacts: {
784
+ token_budget: join(root, getDispatchTokenBudgetPath(turn.turn_id)),
785
+ effective_context: join(root, getDispatchEffectiveContextPath(turn.turn_id)),
786
+ },
787
+ });
788
+ }
789
+ }
790
+
791
+ const requestBody = buildAnthropicRequest(promptMd, effectiveContextMd, model, maxOutputTokens);
792
+
793
+ // Persist request metadata for auditability
794
+ const dispatchDir = join(root, getDispatchTurnDir(turn.turn_id));
795
+ try {
796
+ writeFileSync(
797
+ join(root, getDispatchApiRequestPath(turn.turn_id)),
798
+ JSON.stringify({
799
+ provider,
800
+ model,
801
+ endpoint,
802
+ max_output_tokens: maxOutputTokens,
803
+ retry_policy: retryPolicy,
804
+ preflight_tokenization: preflightTokenization
805
+ ? {
806
+ enabled: true,
807
+ tokenizer: preflightTokenization.tokenizer,
808
+ context_window_tokens: preflightTokenization.context_window_tokens,
809
+ safety_margin_tokens: preflightTokenization.safety_margin_tokens,
810
+ }
811
+ : null,
812
+ timestamp: new Date().toISOString(),
813
+ // Do not persist the API key
814
+ }, null, 2) + '\n'
815
+ );
816
+ } catch {
817
+ // best-effort audit artifact
818
+ }
819
+
820
+ onStatus?.(`Sending request to ${provider} (${model})...`);
821
+ const aggregateUsage = emptyUsageTotals();
822
+ let hasUsageTelemetry = false;
823
+ let attemptsMade = 0;
824
+ let execution;
825
+ const traceAttempts = [];
826
+ let pendingScheduledDelayMs = 0;
827
+ let pendingActualDelayMs = 0;
828
+
829
+ while (true) {
830
+ attemptsMade += 1;
831
+ const attemptStartedAt = new Date().toISOString();
832
+ const scheduledDelayMs = pendingScheduledDelayMs;
833
+ const actualDelayMs = pendingActualDelayMs;
834
+ pendingScheduledDelayMs = 0;
835
+ pendingActualDelayMs = 0;
836
+
837
+ execution = await executeApiCall({
838
+ endpoint,
839
+ apiKey,
840
+ provider,
841
+ model,
842
+ authEnv,
843
+ requestBody,
844
+ timeoutSeconds,
845
+ signal,
846
+ });
847
+
848
+ const attemptCompletedAt = new Date().toISOString();
849
+
850
+ if (execution.usage) {
851
+ hasUsageTelemetry = true;
852
+ const totals = addUsageTotals(aggregateUsage, execution.usage);
853
+ aggregateUsage.input_tokens = totals.input_tokens;
854
+ aggregateUsage.output_tokens = totals.output_tokens;
855
+ aggregateUsage.usd = totals.usd;
856
+ }
857
+
858
+ if (execution.ok) {
859
+ traceAttempts.push({
860
+ attempt: attemptsMade,
861
+ started_at: attemptStartedAt,
862
+ completed_at: attemptCompletedAt,
863
+ outcome: 'success',
864
+ retryable: false,
865
+ http_status: null,
866
+ scheduled_delay_ms: scheduledDelayMs,
867
+ actual_delay_ms: actualDelayMs,
868
+ usage: execution.usage || null,
869
+ });
870
+ break;
871
+ }
872
+
873
+ if (execution.aborted) {
874
+ traceAttempts.push({
875
+ attempt: attemptsMade,
876
+ started_at: attemptStartedAt,
877
+ completed_at: attemptCompletedAt,
878
+ outcome: 'aborted',
879
+ retryable: false,
880
+ http_status: null,
881
+ scheduled_delay_ms: scheduledDelayMs,
882
+ actual_delay_ms: actualDelayMs,
883
+ usage: execution.usage || null,
884
+ });
885
+ const tracePath = writeRetryTrace(root, turn.turn_id, provider, model, state, runtimeId, retryPolicy, attemptsMade, 'aborted', aggregateUsage, traceAttempts);
886
+ return { ok: false, error: execution.error, attempts_made: attemptsMade, retry_trace_path: tracePath };
887
+ }
888
+
889
+ const classified = execution.classified;
890
+ traceAttempts.push({
891
+ attempt: attemptsMade,
892
+ started_at: attemptStartedAt,
893
+ completed_at: attemptCompletedAt,
894
+ outcome: classified?.error_class || 'non_retryable_error',
895
+ retryable: classified?.retryable || false,
896
+ http_status: classified?.http_status ?? null,
897
+ provider_error_type: classified?.provider_error_type ?? null,
898
+ scheduled_delay_ms: scheduledDelayMs,
899
+ actual_delay_ms: actualDelayMs,
900
+ usage: execution.usage || null,
901
+ });
902
+
903
+ if (!shouldRetryAttempt(classified, retryPolicy, attemptsMade)) {
904
+ // Persist raw provider response on failure for debugging (e.g. extraction failure)
905
+ if (execution.responseData) {
906
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
907
+ mkdirSync(stagingDir, { recursive: true });
908
+ try {
909
+ writeFileSync(
910
+ join(root, getTurnProviderResponsePath(turn.turn_id)),
911
+ JSON.stringify(execution.responseData, null, 2) + '\n'
912
+ );
913
+ } catch {
914
+ // best-effort audit artifact
915
+ }
916
+ }
917
+ const tracePath = writeRetryTrace(root, turn.turn_id, provider, model, state, runtimeId, retryPolicy, attemptsMade, 'failure', aggregateUsage, traceAttempts);
918
+ return errorReturn(root, turn.turn_id, classified, { attempts_made: attemptsMade, retry_trace_path: tracePath });
919
+ }
920
+
921
+ const nextAttemptNumber = attemptsMade + 1;
922
+ const retryDelayMs = calculateRetryDelayMs(retryPolicy, nextAttemptNumber);
923
+ onStatus?.(
924
+ `Attempt ${attemptsMade}/${retryPolicy.max_attempts} failed with ${classified.error_class}; retrying in ${retryDelayMs}ms...`
925
+ );
926
+
927
+ const delayStart = Date.now();
928
+ try {
929
+ await waitForRetryDelay(retryDelayMs, signal);
930
+ } catch {
931
+ const tracePath = writeRetryTrace(root, turn.turn_id, provider, model, state, runtimeId, retryPolicy, attemptsMade, 'aborted', aggregateUsage, traceAttempts);
932
+ return { ok: false, error: 'Dispatch aborted by operator', attempts_made: attemptsMade, retry_trace_path: tracePath };
933
+ }
934
+ pendingScheduledDelayMs = retryDelayMs;
935
+ pendingActualDelayMs = Date.now() - delayStart;
936
+ }
937
+
938
+ // Write trace on success too (only when retries were attempted)
939
+ let retryTracePath = undefined;
940
+ if (attemptsMade > 1) {
941
+ retryTracePath = writeRetryTrace(root, turn.turn_id, provider, model, state, runtimeId, retryPolicy, attemptsMade, 'success', aggregateUsage, traceAttempts);
942
+ }
943
+
944
+ const { responseData, turnResult } = execution;
945
+
946
+ // Persist raw response for auditability
947
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
948
+ mkdirSync(stagingDir, { recursive: true });
949
+
950
+ try {
951
+ writeFileSync(
952
+ join(root, getTurnProviderResponsePath(turn.turn_id)),
953
+ JSON.stringify(responseData, null, 2) + '\n'
954
+ );
955
+ } catch {
956
+ // best-effort audit artifact
957
+ }
958
+
959
+ if (hasUsageTelemetry && turnResult) {
960
+ turnResult.cost = { ...aggregateUsage };
961
+ }
962
+
963
+ // Stage the turn result
964
+ try {
965
+ writeFileSync(
966
+ join(root, getTurnStagingResultPath(turn.turn_id)),
967
+ JSON.stringify(turnResult, null, 2) + '\n'
968
+ );
969
+ } catch (err) {
970
+ return { ok: false, error: `Failed to stage turn result: ${err.message}` };
971
+ }
972
+
973
+ clearApiError(root, turn.turn_id);
974
+ onStatus?.('Turn result staged successfully.');
975
+
976
+ return {
977
+ ok: true,
978
+ staged: true,
979
+ usage: hasUsageTelemetry ? { ...aggregateUsage } : null,
980
+ attempts_made: attemptsMade,
981
+ retry_trace_path: retryTracePath,
982
+ };
983
+ }
984
+
985
+ // ── Anthropic-specific request/response handling ────────────────────────────
986
+
987
+ function buildAnthropicHeaders(apiKey) {
988
+ return {
989
+ 'Content-Type': 'application/json',
990
+ 'x-api-key': apiKey,
991
+ 'anthropic-version': '2023-06-01',
992
+ };
993
+ }
994
+
995
+ function buildAnthropicRequest(promptMd, contextMd, model, maxOutputTokens) {
996
+ const userContent = contextMd
997
+ ? `${promptMd}${SEPARATOR}${contextMd}`
998
+ : promptMd;
999
+
1000
+ return {
1001
+ model,
1002
+ max_tokens: maxOutputTokens,
1003
+ system: SYSTEM_PROMPT,
1004
+ messages: [
1005
+ { role: 'user', content: userContent },
1006
+ ],
1007
+ };
1008
+ }
1009
+
1010
+ /**
1011
+ * Extract structured turn result JSON from an Anthropic API response.
1012
+ * Looks for JSON in the first text content block.
1013
+ */
1014
+ function extractTurnResult(responseData) {
1015
+ if (!responseData?.content || !Array.isArray(responseData.content)) {
1016
+ return { ok: false, error: 'API response has no content blocks' };
1017
+ }
1018
+
1019
+ const textBlock = responseData.content.find(b => b.type === 'text');
1020
+ if (!textBlock?.text) {
1021
+ return { ok: false, error: 'API response has no text content block' };
1022
+ }
1023
+
1024
+ const text = textBlock.text.trim();
1025
+
1026
+ // Try parsing the entire response as JSON first
1027
+ try {
1028
+ const parsed = JSON.parse(text);
1029
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
1030
+ return { ok: true, turnResult: parsed };
1031
+ }
1032
+ } catch {
1033
+ // Not pure JSON — try extracting from markdown fences
1034
+ }
1035
+
1036
+ // Try extracting JSON from markdown code fences
1037
+ const fenceMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
1038
+ if (fenceMatch) {
1039
+ try {
1040
+ const parsed = JSON.parse(fenceMatch[1].trim());
1041
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
1042
+ return { ok: true, turnResult: parsed };
1043
+ }
1044
+ } catch {
1045
+ // Invalid JSON inside fence
1046
+ }
1047
+ }
1048
+
1049
+ // Try finding JSON object boundaries
1050
+ const jsonStart = text.indexOf('{');
1051
+ const jsonEnd = text.lastIndexOf('}');
1052
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
1053
+ try {
1054
+ const parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
1055
+ if (parsed && typeof parsed === 'object' && parsed.schema_version) {
1056
+ return { ok: true, turnResult: parsed };
1057
+ }
1058
+ } catch {
1059
+ // Not valid JSON
1060
+ }
1061
+ }
1062
+
1063
+ return {
1064
+ ok: false,
1065
+ error: 'Could not extract structured turn result JSON from API response. The model did not return valid turn result JSON.',
1066
+ };
1067
+ }
1068
+
1069
+ function resolveTargetTurn(state, turnId) {
1070
+ if (turnId && state?.active_turns?.[turnId]) {
1071
+ return state.active_turns[turnId];
1072
+ }
1073
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
1074
+ }
1075
+
1076
+ export { extractTurnResult, buildAnthropicRequest, classifyError, classifyHttpError, COST_RATES };