agentxchain 2.2.0 → 2.4.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.
@@ -19,7 +19,7 @@
19
19
  * All error returns include a `classified` ApiProxyError object with
20
20
  * error_class, recovery instructions, and retryable flag.
21
21
  *
22
- * Supported providers: "anthropic" (others can be added behind the same interface)
22
+ * Supported providers: "anthropic", "openai"
23
23
  */
24
24
 
25
25
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
@@ -43,6 +43,7 @@ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
43
43
  // Provider endpoint registry
44
44
  const PROVIDER_ENDPOINTS = {
45
45
  anthropic: 'https://api.anthropic.com/v1/messages',
46
+ openai: 'https://api.openai.com/v1/chat/completions',
46
47
  };
47
48
 
48
49
  // Cost rates per million tokens (USD)
@@ -91,6 +92,21 @@ const PROVIDER_ERROR_MAPS = {
91
92
  { provider_error_type: 'api_error', http_status: 500, error_class: 'unknown_api_error', retryable: true },
92
93
  ],
93
94
  },
95
+ openai: {
96
+ extractErrorType(body) {
97
+ return typeof body?.error?.type === 'string' ? body.error.type : null;
98
+ },
99
+ extractErrorCode(body) {
100
+ return typeof body?.error?.code === 'string' ? body.error.code : null;
101
+ },
102
+ mappings: [
103
+ { provider_error_code: 'invalid_api_key', http_status: 401, error_class: 'auth_failure', retryable: false },
104
+ { provider_error_code: 'model_not_found', http_status: 404, error_class: 'model_not_found', retryable: false },
105
+ { provider_error_type: 'invalid_request_error', http_status: 400, body_pattern: /context|token.*limit|too.many.tokens/i, error_class: 'context_overflow', retryable: false },
106
+ { provider_error_type: 'invalid_request_error', http_status: 400, error_class: 'invalid_request', retryable: false },
107
+ { provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
108
+ ],
109
+ },
94
110
  };
95
111
 
96
112
  // ── Error classification ──────────────────────────────────────────────────────
@@ -261,7 +277,8 @@ function classifyProviderHttpError(status, body, provider, model, authEnv) {
261
277
  }
262
278
 
263
279
  for (const mapping of providerMap.mappings) {
264
- if (mapping.provider_error_type !== providerErrorType) continue;
280
+ if (mapping.provider_error_type && mapping.provider_error_type !== providerErrorType) continue;
281
+ if (mapping.provider_error_code && mapping.provider_error_code !== providerErrorCode) continue;
265
282
  if (!httpStatusMatches(mapping.http_status, status)) continue;
266
283
  if (mapping.body_pattern && !mapping.body_pattern.test(body)) continue;
267
284
  return {
@@ -388,11 +405,20 @@ function emptyUsageTotals() {
388
405
  };
389
406
  }
390
407
 
391
- function usageFromTelemetry(model, usage) {
408
+ function usageFromTelemetry(provider, model, usage) {
392
409
  if (!usage || typeof usage !== 'object') return null;
393
410
 
394
- const inputTokens = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
395
- const outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
411
+ let inputTokens = 0;
412
+ let outputTokens = 0;
413
+
414
+ if (provider === 'openai') {
415
+ inputTokens = Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : 0;
416
+ outputTokens = Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : 0;
417
+ } else {
418
+ inputTokens = Number.isFinite(usage.input_tokens) ? usage.input_tokens : 0;
419
+ outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
420
+ }
421
+
396
422
  const rates = COST_RATES[model];
397
423
  const usd = rates
398
424
  ? (inputTokens / 1_000_000) * rates.input_per_1m + (outputTokens / 1_000_000) * rates.output_per_1m
@@ -567,7 +593,7 @@ async function executeApiCall({
567
593
  try {
568
594
  response = await fetch(endpoint, {
569
595
  method: 'POST',
570
- headers: buildAnthropicHeaders(apiKey),
596
+ headers: buildProviderHeaders(provider, apiKey),
571
597
  body: JSON.stringify(requestBody),
572
598
  signal: controller.signal,
573
599
  });
@@ -636,8 +662,8 @@ async function executeApiCall({
636
662
  };
637
663
  }
638
664
 
639
- const usage = usageFromTelemetry(model, responseData.usage);
640
- const extraction = extractTurnResult(responseData);
665
+ const usage = usageFromTelemetry(provider, model, responseData.usage);
666
+ const extraction = extractTurnResult(responseData, provider);
641
667
 
642
668
  if (!extraction.ok) {
643
669
  return {
@@ -788,7 +814,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
788
814
  }
789
815
  }
790
816
 
791
- const requestBody = buildAnthropicRequest(promptMd, effectiveContextMd, model, maxOutputTokens);
817
+ const requestBody = buildProviderRequest(provider, promptMd, effectiveContextMd, model, maxOutputTokens);
792
818
 
793
819
  // Persist request metadata for auditability
794
820
  const dispatchDir = join(root, getDispatchTurnDir(turn.turn_id));
@@ -1007,25 +1033,59 @@ function buildAnthropicRequest(promptMd, contextMd, model, maxOutputTokens) {
1007
1033
  };
1008
1034
  }
1009
1035
 
1036
+ function buildOpenAiHeaders(apiKey) {
1037
+ return {
1038
+ 'Content-Type': 'application/json',
1039
+ Authorization: `Bearer ${apiKey}`,
1040
+ };
1041
+ }
1042
+
1043
+ function buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens) {
1044
+ const userContent = contextMd
1045
+ ? `${promptMd}${SEPARATOR}${contextMd}`
1046
+ : promptMd;
1047
+
1048
+ return {
1049
+ model,
1050
+ max_completion_tokens: maxOutputTokens,
1051
+ response_format: { type: 'json_object' },
1052
+ messages: [
1053
+ { role: 'developer', content: SYSTEM_PROMPT },
1054
+ { role: 'user', content: userContent },
1055
+ ],
1056
+ };
1057
+ }
1058
+
1059
+ function buildProviderHeaders(provider, apiKey) {
1060
+ if (provider === 'openai') {
1061
+ return buildOpenAiHeaders(apiKey);
1062
+ }
1063
+ return buildAnthropicHeaders(apiKey);
1064
+ }
1065
+
1066
+ function buildProviderRequest(provider, promptMd, contextMd, model, maxOutputTokens) {
1067
+ if (provider === 'openai') {
1068
+ return buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
1069
+ }
1070
+ return buildAnthropicRequest(promptMd, contextMd, model, maxOutputTokens);
1071
+ }
1072
+
1010
1073
  /**
1011
1074
  * Extract structured turn result JSON from an Anthropic API response.
1012
1075
  * Looks for JSON in the first text content block.
1013
1076
  */
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' };
1077
+ function extractTurnResultFromText(text) {
1078
+ if (typeof text !== 'string' || !text.trim()) {
1079
+ return {
1080
+ ok: false,
1081
+ error: 'Could not extract structured turn result JSON from API response. The model did not return valid turn result JSON.',
1082
+ };
1022
1083
  }
1023
1084
 
1024
- const text = textBlock.text.trim();
1085
+ const trimmed = text.trim();
1025
1086
 
1026
- // Try parsing the entire response as JSON first
1027
1087
  try {
1028
- const parsed = JSON.parse(text);
1088
+ const parsed = JSON.parse(trimmed);
1029
1089
  if (parsed && typeof parsed === 'object' && parsed.schema_version) {
1030
1090
  return { ok: true, turnResult: parsed };
1031
1091
  }
@@ -1033,8 +1093,7 @@ function extractTurnResult(responseData) {
1033
1093
  // Not pure JSON — try extracting from markdown fences
1034
1094
  }
1035
1095
 
1036
- // Try extracting JSON from markdown code fences
1037
- const fenceMatch = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
1096
+ const fenceMatch = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
1038
1097
  if (fenceMatch) {
1039
1098
  try {
1040
1099
  const parsed = JSON.parse(fenceMatch[1].trim());
@@ -1046,12 +1105,11 @@ function extractTurnResult(responseData) {
1046
1105
  }
1047
1106
  }
1048
1107
 
1049
- // Try finding JSON object boundaries
1050
- const jsonStart = text.indexOf('{');
1051
- const jsonEnd = text.lastIndexOf('}');
1108
+ const jsonStart = trimmed.indexOf('{');
1109
+ const jsonEnd = trimmed.lastIndexOf('}');
1052
1110
  if (jsonStart >= 0 && jsonEnd > jsonStart) {
1053
1111
  try {
1054
- const parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
1112
+ const parsed = JSON.parse(trimmed.slice(jsonStart, jsonEnd + 1));
1055
1113
  if (parsed && typeof parsed === 'object' && parsed.schema_version) {
1056
1114
  return { ok: true, turnResult: parsed };
1057
1115
  }
@@ -1066,6 +1124,39 @@ function extractTurnResult(responseData) {
1066
1124
  };
1067
1125
  }
1068
1126
 
1127
+ function extractAnthropicTurnResult(responseData) {
1128
+ if (!responseData?.content || !Array.isArray(responseData.content)) {
1129
+ return { ok: false, error: 'API response has no content blocks' };
1130
+ }
1131
+
1132
+ const textBlock = responseData.content.find(b => b.type === 'text');
1133
+ if (!textBlock?.text) {
1134
+ return { ok: false, error: 'API response has no text content block' };
1135
+ }
1136
+
1137
+ return extractTurnResultFromText(textBlock.text);
1138
+ }
1139
+
1140
+ function extractOpenAiTurnResult(responseData) {
1141
+ if (!Array.isArray(responseData?.choices) || responseData.choices.length === 0) {
1142
+ return { ok: false, error: 'API response has no choices' };
1143
+ }
1144
+
1145
+ const content = responseData.choices[0]?.message?.content;
1146
+ if (typeof content !== 'string' || !content.trim()) {
1147
+ return { ok: false, error: 'API response has no message content' };
1148
+ }
1149
+
1150
+ return extractTurnResultFromText(content);
1151
+ }
1152
+
1153
+ function extractTurnResult(responseData, provider = 'anthropic') {
1154
+ if (provider === 'openai') {
1155
+ return extractOpenAiTurnResult(responseData);
1156
+ }
1157
+ return extractAnthropicTurnResult(responseData);
1158
+ }
1159
+
1069
1160
  function resolveTargetTurn(state, turnId) {
1070
1161
  if (turnId && state?.active_turns?.[turnId]) {
1071
1162
  return state.active_turns[turnId];
@@ -1073,4 +1164,11 @@ function resolveTargetTurn(state, turnId) {
1073
1164
  return state?.current_turn || Object.values(state?.active_turns || {})[0];
1074
1165
  }
1075
1166
 
1076
- export { extractTurnResult, buildAnthropicRequest, classifyError, classifyHttpError, COST_RATES };
1167
+ export {
1168
+ extractTurnResult,
1169
+ buildAnthropicRequest,
1170
+ buildOpenAiRequest,
1171
+ classifyError,
1172
+ classifyHttpError,
1173
+ COST_RATES,
1174
+ };
@@ -0,0 +1,306 @@
1
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5
+ import {
6
+ getDispatchAssignmentPath,
7
+ getDispatchContextPath,
8
+ getDispatchPromptPath,
9
+ getDispatchTurnDir,
10
+ getTurnStagingDir,
11
+ getTurnStagingResultPath,
12
+ } from '../turn-paths.js';
13
+ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
14
+
15
+ export const DEFAULT_MCP_TOOL_NAME = 'agentxchain_turn';
16
+
17
+ /**
18
+ * Dispatch a governed turn to an MCP server over stdio.
19
+ *
20
+ * v1 scope:
21
+ * - stdio transport only
22
+ * - single tool call per turn
23
+ * - required governed-turn tool contract
24
+ * - synchronous dispatch/wait flow (like api_proxy)
25
+ *
26
+ * The MCP tool must return a valid AgentXchain turn result either as:
27
+ * - structuredContent, or
28
+ * - JSON text in a text content block
29
+ */
30
+ export async function dispatchMcp(root, state, config, options = {}) {
31
+ const { signal, onStatus, onStderr, turnId } = options;
32
+
33
+ const turn = resolveTargetTurn(state, turnId);
34
+ if (!turn) {
35
+ return { ok: false, error: 'No active turn in state' };
36
+ }
37
+
38
+ const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
39
+ if (!manifestCheck.ok) {
40
+ return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}` };
41
+ }
42
+
43
+ const runtimeId = turn.runtime_id;
44
+ const runtime = config.runtimes?.[runtimeId];
45
+ if (!runtime) {
46
+ return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
47
+ }
48
+
49
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
50
+ const contextPath = join(root, getDispatchContextPath(turn.turn_id));
51
+ if (!existsSync(promptPath)) {
52
+ return { ok: false, error: 'Dispatch bundle not found. Run writeDispatchBundle() first.' };
53
+ }
54
+
55
+ const prompt = readFileSync(promptPath, 'utf8');
56
+ const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
57
+ const { command, args } = resolveMcpCommand(runtime);
58
+ if (!command) {
59
+ return { ok: false, error: `Cannot resolve MCP command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
60
+ }
61
+
62
+ const timeoutMs = turn.deadline_at
63
+ ? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
64
+ : 1200000;
65
+ const toolName = resolveMcpToolName(runtime);
66
+ const logs = [];
67
+
68
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
69
+ mkdirSync(stagingDir, { recursive: true });
70
+
71
+ const transport = new StdioClientTransport({
72
+ command,
73
+ args,
74
+ cwd: runtime.cwd ? join(root, runtime.cwd) : root,
75
+ env: buildTransportEnv(process.env),
76
+ stderr: 'pipe',
77
+ });
78
+
79
+ if (transport.stderr) {
80
+ transport.stderr.on('data', (chunk) => {
81
+ const text = chunk.toString();
82
+ logs.push(`[stderr] ${text}`);
83
+ if (onStderr) onStderr(text);
84
+ });
85
+ }
86
+
87
+ const client = new Client({
88
+ name: 'agentxchain-mcp-adapter',
89
+ version: '1.0.0',
90
+ });
91
+ client.onerror = (error) => {
92
+ logs.push(`[client-error] ${error.message}`);
93
+ };
94
+ transport.onerror = (error) => {
95
+ logs.push(`[transport-error] ${error.message}`);
96
+ };
97
+
98
+ try {
99
+ if (signal?.aborted) {
100
+ return { ok: false, aborted: true, logs };
101
+ }
102
+
103
+ onStatus?.(`Connecting to MCP stdio server (${command})`);
104
+ await client.connect(transport);
105
+
106
+ if (signal?.aborted) {
107
+ return { ok: false, aborted: true, logs };
108
+ }
109
+
110
+ onStatus?.(`Listing MCP tools`);
111
+ const toolsResult = await client.listTools(undefined, {
112
+ signal,
113
+ timeout: timeoutMs,
114
+ maxTotalTimeout: timeoutMs,
115
+ });
116
+ const toolNames = toolsResult.tools.map((tool) => tool.name);
117
+ if (!toolNames.includes(toolName)) {
118
+ return {
119
+ ok: false,
120
+ error: `MCP server does not expose required tool "${toolName}". Available tools: ${toolNames.join(', ') || '(none)'}`,
121
+ logs,
122
+ };
123
+ }
124
+
125
+ if (signal?.aborted) {
126
+ return { ok: false, aborted: true, logs };
127
+ }
128
+
129
+ onStatus?.(`Calling MCP tool "${toolName}"`);
130
+ const toolResult = await client.callTool({
131
+ name: toolName,
132
+ arguments: {
133
+ run_id: state.run_id,
134
+ turn_id: turn.turn_id,
135
+ role: turn.assigned_role,
136
+ phase: state.phase,
137
+ runtime_id: runtimeId,
138
+ project_root: root,
139
+ dispatch_dir: join(root, getDispatchTurnDir(turn.turn_id)),
140
+ assignment_path: join(root, getDispatchAssignmentPath(turn.turn_id)),
141
+ prompt_path: promptPath,
142
+ context_path: contextPath,
143
+ staging_path: join(root, getTurnStagingResultPath(turn.turn_id)),
144
+ prompt,
145
+ context,
146
+ },
147
+ }, undefined, {
148
+ signal,
149
+ timeout: timeoutMs,
150
+ maxTotalTimeout: timeoutMs,
151
+ resetTimeoutOnProgress: true,
152
+ });
153
+
154
+ if (toolResult?.isError) {
155
+ return {
156
+ ok: false,
157
+ error: buildMcpToolError(toolName, toolResult),
158
+ logs,
159
+ };
160
+ }
161
+
162
+ const turnResult = extractTurnResultFromMcpToolResult(toolResult);
163
+ if (!turnResult.ok) {
164
+ return { ok: false, error: turnResult.error, logs };
165
+ }
166
+
167
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
168
+ writeFileSync(stagingPath, JSON.stringify(turnResult.result, null, 2));
169
+
170
+ return {
171
+ ok: true,
172
+ toolName,
173
+ logs,
174
+ };
175
+ } catch (error) {
176
+ if (signal?.aborted) {
177
+ return { ok: false, aborted: true, logs };
178
+ }
179
+ return {
180
+ ok: false,
181
+ error: `MCP dispatch failed: ${error.message}`,
182
+ logs,
183
+ };
184
+ } finally {
185
+ await safeCloseClient(client, transport);
186
+ }
187
+ }
188
+
189
+ export function resolveMcpCommand(runtime) {
190
+ if (!runtime?.command) return { command: null, args: [] };
191
+
192
+ if (Array.isArray(runtime.command)) {
193
+ const [command, ...args] = runtime.command;
194
+ return { command, args };
195
+ }
196
+
197
+ return {
198
+ command: runtime.command,
199
+ args: Array.isArray(runtime.args) ? runtime.args : [],
200
+ };
201
+ }
202
+
203
+ export function resolveMcpToolName(runtime) {
204
+ return typeof runtime?.tool_name === 'string' && runtime.tool_name.trim()
205
+ ? runtime.tool_name.trim()
206
+ : DEFAULT_MCP_TOOL_NAME;
207
+ }
208
+
209
+ export function extractTurnResultFromMcpToolResult(toolResult) {
210
+ const directCandidates = [
211
+ toolResult?.structuredContent,
212
+ toolResult?.toolResult?.structuredContent,
213
+ toolResult?.toolResult,
214
+ ];
215
+
216
+ for (const candidate of directCandidates) {
217
+ if (looksLikeTurnResult(candidate)) {
218
+ return { ok: true, result: candidate };
219
+ }
220
+ }
221
+
222
+ const textBlocks = [
223
+ ...(Array.isArray(toolResult?.content) ? toolResult.content : []),
224
+ ...(Array.isArray(toolResult?.toolResult?.content) ? toolResult.toolResult.content : []),
225
+ ].filter((item) => item?.type === 'text' && typeof item.text === 'string');
226
+
227
+ for (const block of textBlocks) {
228
+ const parsed = tryParseJson(block.text);
229
+ if (looksLikeTurnResult(parsed) || isPlainObject(parsed)) {
230
+ return { ok: true, result: parsed };
231
+ }
232
+ }
233
+
234
+ if (textBlocks.length === 0) {
235
+ return {
236
+ ok: false,
237
+ error: 'MCP tool returned no structuredContent and no text content blocks containing a turn result.',
238
+ };
239
+ }
240
+
241
+ return {
242
+ ok: false,
243
+ error: 'MCP tool returned text content, but none of the text blocks contained valid turn-result JSON.',
244
+ };
245
+ }
246
+
247
+ function buildMcpToolError(toolName, toolResult) {
248
+ const text = Array.isArray(toolResult?.content)
249
+ ? toolResult.content
250
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string')
251
+ .map((item) => item.text.trim())
252
+ .filter(Boolean)
253
+ .join(' ')
254
+ : '';
255
+ if (text) {
256
+ return `MCP tool "${toolName}" returned an error: ${text}`;
257
+ }
258
+ return `MCP tool "${toolName}" returned an error without diagnostic text.`;
259
+ }
260
+
261
+ function buildTransportEnv(env) {
262
+ const result = {};
263
+ for (const [key, value] of Object.entries(env || {})) {
264
+ if (typeof value === 'string') {
265
+ result[key] = value;
266
+ }
267
+ }
268
+ return result;
269
+ }
270
+
271
+ function isPlainObject(value) {
272
+ return !!value && typeof value === 'object' && !Array.isArray(value);
273
+ }
274
+
275
+ function looksLikeTurnResult(value) {
276
+ if (!isPlainObject(value)) return false;
277
+ const hasIdentity = 'run_id' in value || 'turn_id' in value;
278
+ const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
279
+ return hasIdentity && hasLifecycle;
280
+ }
281
+
282
+ function tryParseJson(value) {
283
+ try {
284
+ return JSON.parse(value);
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ function resolveTargetTurn(state, turnId) {
291
+ if (turnId && state?.active_turns?.[turnId]) {
292
+ return state.active_turns[turnId];
293
+ }
294
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
295
+ }
296
+
297
+ async function safeCloseClient(client, transport) {
298
+ try {
299
+ await client.close();
300
+ return;
301
+ } catch {}
302
+
303
+ try {
304
+ await transport.close();
305
+ } catch {}
306
+ }