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.
- package/README.md +136 -136
- package/bin/agentxchain.js +186 -5
- package/dashboard/app.js +305 -0
- package/dashboard/components/blocked.js +145 -0
- package/dashboard/components/cross-repo.js +126 -0
- package/dashboard/components/gate.js +311 -0
- package/dashboard/components/hooks.js +177 -0
- package/dashboard/components/initiative.js +147 -0
- package/dashboard/components/ledger.js +165 -0
- package/dashboard/components/timeline.js +222 -0
- package/dashboard/index.html +352 -0
- package/package.json +14 -6
- package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
- package/scripts/publish-from-tag.sh +88 -0
- package/scripts/release-postflight.sh +231 -0
- package/scripts/release-preflight.sh +167 -0
- package/src/commands/accept-turn.js +160 -0
- package/src/commands/approve-completion.js +80 -0
- package/src/commands/approve-transition.js +85 -0
- package/src/commands/dashboard.js +70 -0
- package/src/commands/init.js +516 -0
- package/src/commands/migrate.js +348 -0
- package/src/commands/multi.js +549 -0
- package/src/commands/plugin.js +157 -0
- package/src/commands/reject-turn.js +204 -0
- package/src/commands/resume.js +389 -0
- package/src/commands/status.js +196 -3
- package/src/commands/step.js +947 -0
- package/src/commands/template-list.js +33 -0
- package/src/commands/template-set.js +279 -0
- package/src/commands/validate.js +20 -11
- package/src/commands/verify.js +71 -0
- package/src/lib/adapters/api-proxy-adapter.js +1076 -0
- package/src/lib/adapters/local-cli-adapter.js +337 -0
- package/src/lib/adapters/manual-adapter.js +169 -0
- package/src/lib/blocked-state.js +94 -0
- package/src/lib/config.js +97 -1
- package/src/lib/context-compressor.js +121 -0
- package/src/lib/context-section-parser.js +220 -0
- package/src/lib/coordinator-acceptance.js +428 -0
- package/src/lib/coordinator-config.js +461 -0
- package/src/lib/coordinator-dispatch.js +276 -0
- package/src/lib/coordinator-gates.js +487 -0
- package/src/lib/coordinator-hooks.js +239 -0
- package/src/lib/coordinator-recovery.js +523 -0
- package/src/lib/coordinator-state.js +365 -0
- package/src/lib/cross-repo-context.js +247 -0
- package/src/lib/dashboard/bridge-server.js +284 -0
- package/src/lib/dashboard/file-watcher.js +93 -0
- package/src/lib/dashboard/state-reader.js +96 -0
- package/src/lib/dispatch-bundle.js +568 -0
- package/src/lib/dispatch-manifest.js +252 -0
- package/src/lib/gate-evaluator.js +285 -0
- package/src/lib/governed-state.js +2139 -0
- package/src/lib/governed-templates.js +145 -0
- package/src/lib/hook-runner.js +788 -0
- package/src/lib/normalized-config.js +539 -0
- package/src/lib/plugin-config-schema.js +192 -0
- package/src/lib/plugins.js +692 -0
- package/src/lib/protocol-conformance.js +291 -0
- package/src/lib/reference-conformance-adapter.js +858 -0
- package/src/lib/repo-observer.js +597 -0
- package/src/lib/repo.js +0 -31
- package/src/lib/schema.js +121 -0
- package/src/lib/schemas/turn-result.schema.json +205 -0
- package/src/lib/token-budget.js +206 -0
- package/src/lib/token-counter.js +27 -0
- package/src/lib/turn-paths.js +67 -0
- package/src/lib/turn-result-validator.js +496 -0
- package/src/lib/validation.js +137 -0
- package/src/templates/governed/api-service.json +31 -0
- package/src/templates/governed/cli-tool.json +30 -0
- package/src/templates/governed/generic.json +10 -0
- 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 };
|