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.
- package/README.md +17 -0
- package/bin/agentxchain.js +97 -1
- package/package.json +10 -3
- package/scripts/publish-from-tag.sh +14 -9
- package/scripts/release-postflight.sh +42 -2
- package/src/commands/init.js +1 -0
- package/src/commands/intake-approve.js +44 -0
- package/src/commands/intake-plan.js +62 -0
- package/src/commands/intake-record.js +86 -0
- package/src/commands/intake-resolve.js +45 -0
- package/src/commands/intake-scan.js +87 -0
- package/src/commands/intake-start.js +53 -0
- package/src/commands/intake-status.js +113 -0
- package/src/commands/intake-triage.js +54 -0
- package/src/commands/step.js +56 -2
- package/src/commands/template-validate.js +159 -0
- package/src/commands/verify.js +8 -3
- package/src/lib/adapters/api-proxy-adapter.js +125 -27
- package/src/lib/adapters/mcp-adapter.js +306 -0
- package/src/lib/governed-templates.js +236 -1
- package/src/lib/intake.js +924 -0
- package/src/lib/normalized-config.js +44 -1
- package/src/lib/protocol-conformance.js +28 -4
- package/src/lib/repo-observer.js +9 -8
- package/src/lib/validation.js +23 -0
- package/src/templates/governed/library.json +31 -0
|
@@ -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"
|
|
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
|
-
|
|
395
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
1015
|
-
if (
|
|
1016
|
-
return {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
1085
|
+
const trimmed = text.trim();
|
|
1025
1086
|
|
|
1026
|
-
// Try parsing the entire response as JSON first
|
|
1027
1087
|
try {
|
|
1028
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
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
|
-
|
|
1050
|
-
const
|
|
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(
|
|
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 {
|
|
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
|
+
}
|