agentxchain 2.28.0 → 2.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -2
- package/src/commands/run.js +5 -1
- package/src/commands/step.js +56 -1
- package/src/lib/adapter-interface.js +6 -0
- package/src/lib/adapters/remote-agent-adapter.js +257 -0
- package/src/lib/dispatch-bundle.js +4 -4
- package/src/lib/governed-state.js +8 -2
- package/src/lib/normalized-config.js +50 -4
- package/src/lib/turn-result-validator.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentxchain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.29.0",
|
|
4
4
|
"description": "CLI for AgentXchain — governed multi-agent software delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -62,7 +62,6 @@
|
|
|
62
62
|
"chalk": "^5.4.0",
|
|
63
63
|
"commander": "^13.0.0",
|
|
64
64
|
"inquirer": "^12.0.0",
|
|
65
|
-
"ora": "^8.0.0",
|
|
66
65
|
"zod": "^4.3.6"
|
|
67
66
|
},
|
|
68
67
|
"engines": {
|
package/src/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* agentxchain run — drive a governed run to completion.
|
|
3
3
|
*
|
|
4
4
|
* Thin CLI surface over the runLoop library. Wires runLoop callbacks to:
|
|
5
|
-
* - Existing adapter system (api_proxy, local_cli, mcp)
|
|
5
|
+
* - Existing adapter system (api_proxy, local_cli, mcp, remote_agent)
|
|
6
6
|
* - Interactive gate prompting (stdin) or auto-approve mode
|
|
7
7
|
* - Terminal output via chalk
|
|
8
8
|
*
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resolvePromptTransport,
|
|
28
28
|
} from '../lib/adapters/local-cli-adapter.js';
|
|
29
29
|
import { dispatchMcp, resolveMcpTransport, describeMcpRuntimeTarget } from '../lib/adapters/mcp-adapter.js';
|
|
30
|
+
import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
|
|
30
31
|
import { runHooks } from '../lib/hook-runner.js';
|
|
31
32
|
import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
32
33
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
@@ -200,6 +201,9 @@ export async function runCommand(opts) {
|
|
|
200
201
|
const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
|
|
201
202
|
console.log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
|
|
202
203
|
adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
|
|
204
|
+
} else if (runtimeType === 'remote_agent') {
|
|
205
|
+
console.log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
206
|
+
adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
|
|
203
207
|
} else {
|
|
204
208
|
return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
|
|
205
209
|
}
|
package/src/commands/step.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* - api_proxy: implemented for synchronous review-only turns and stages
|
|
19
19
|
* provider-backed JSON before validation/acceptance
|
|
20
20
|
* - mcp: implemented for synchronous MCP stdio or streamable_http tool dispatch
|
|
21
|
+
* - remote_agent: implemented for synchronous HTTP dispatch to external agent services
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import chalk from 'chalk';
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
resolvePromptTransport,
|
|
50
51
|
} from '../lib/adapters/local-cli-adapter.js';
|
|
51
52
|
import { describeMcpRuntimeTarget, dispatchMcp, resolveMcpTransport } from '../lib/adapters/mcp-adapter.js';
|
|
53
|
+
import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
|
|
52
54
|
import {
|
|
53
55
|
getDispatchAssignmentPath,
|
|
54
56
|
getDispatchContextPath,
|
|
@@ -504,11 +506,63 @@ export async function stepCommand(opts) {
|
|
|
504
506
|
|
|
505
507
|
console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
|
|
506
508
|
console.log('');
|
|
509
|
+
} else if (runtimeType === 'remote_agent') {
|
|
510
|
+
console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
511
|
+
console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
|
|
512
|
+
|
|
513
|
+
const remoteResult = await dispatchRemoteAgent(root, state, config, {
|
|
514
|
+
signal: controller.signal,
|
|
515
|
+
onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
|
|
516
|
+
verifyManifest: true,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (remoteResult.logs?.length) {
|
|
520
|
+
saveDispatchLogs(root, turn.turn_id, remoteResult.logs);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (remoteResult.aborted) {
|
|
524
|
+
console.log('');
|
|
525
|
+
console.log(chalk.yellow('Aborted. Turn remains assigned.'));
|
|
526
|
+
console.log(chalk.dim('Resume later with: agentxchain step --resume'));
|
|
527
|
+
console.log(chalk.dim('Or accept/reject manually: agentxchain accept-turn / reject-turn'));
|
|
528
|
+
process.exit(0);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!remoteResult.ok) {
|
|
532
|
+
const blocked = markRunBlocked(root, {
|
|
533
|
+
blockedOn: `dispatch:remote_agent_failure`,
|
|
534
|
+
category: 'dispatch_error',
|
|
535
|
+
recovery: {
|
|
536
|
+
typed_reason: 'dispatch_error',
|
|
537
|
+
owner: 'human',
|
|
538
|
+
recovery_action: 'Resolve the remote agent dispatch issue, then run agentxchain step --resume',
|
|
539
|
+
turn_retained: true,
|
|
540
|
+
detail: remoteResult.error,
|
|
541
|
+
},
|
|
542
|
+
turnId: turn.turn_id,
|
|
543
|
+
hooksConfig,
|
|
544
|
+
notificationConfig: config,
|
|
545
|
+
});
|
|
546
|
+
if (blocked.ok) {
|
|
547
|
+
state = blocked.state;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
console.log('');
|
|
551
|
+
console.log(chalk.red(`Remote agent dispatch failed: ${remoteResult.error}`));
|
|
552
|
+
console.log(chalk.dim('The turn remains assigned. You can:'));
|
|
553
|
+
console.log(chalk.dim(' - Fix the issue and retry: agentxchain step --resume'));
|
|
554
|
+
console.log(chalk.dim(' - Complete manually: edit .agentxchain/staging/turn-result.json'));
|
|
555
|
+
console.log(chalk.dim(' - Reject: agentxchain reject-turn --reason "remote agent failed"'));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
console.log(chalk.green('Remote agent completed. Staged result detected.'));
|
|
560
|
+
console.log('');
|
|
507
561
|
}
|
|
508
562
|
|
|
509
563
|
// ── Phase 3: Wait for turn completion ─────────────────────────────────────
|
|
510
564
|
|
|
511
|
-
if (runtimeType === 'api_proxy' || runtimeType === 'mcp') {
|
|
565
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'mcp' || runtimeType === 'remote_agent') {
|
|
512
566
|
// api_proxy and mcp are synchronous — result already staged in Phase 2
|
|
513
567
|
} else if (runtimeType === 'local_cli') {
|
|
514
568
|
// ── Local CLI adapter: spawn subprocess ──
|
|
@@ -762,6 +816,7 @@ export async function stepCommand(opts) {
|
|
|
762
816
|
console.log(chalk.dim(' - Fix the staged result and run: agentxchain accept-turn'));
|
|
763
817
|
console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn'));
|
|
764
818
|
console.log(chalk.dim(' - Auto-reject on failure: agentxchain step --auto-reject'));
|
|
819
|
+
process.exit(1);
|
|
765
820
|
}
|
|
766
821
|
}
|
|
767
822
|
}
|
|
@@ -28,4 +28,10 @@ export {
|
|
|
28
28
|
describeMcpRuntimeTarget,
|
|
29
29
|
} from './adapters/mcp-adapter.js';
|
|
30
30
|
|
|
31
|
+
export {
|
|
32
|
+
dispatchRemoteAgent,
|
|
33
|
+
describeRemoteAgentTarget,
|
|
34
|
+
DEFAULT_REMOTE_AGENT_TIMEOUT_MS,
|
|
35
|
+
} from './adapters/remote-agent-adapter.js';
|
|
36
|
+
|
|
31
37
|
export const ADAPTER_INTERFACE_VERSION = '0.1';
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Agent adapter — HTTP-based governed turn dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Specification: .planning/REMOTE_AGENT_BRIDGE_CONNECTOR_SPEC.md
|
|
5
|
+
*
|
|
6
|
+
* This adapter proves connector replaceability (VISION.md Layer 3) by executing
|
|
7
|
+
* governed turns over HTTP against an external agent service. The protocol's
|
|
8
|
+
* staging, validation, and acceptance flow is preserved exactly — the remote
|
|
9
|
+
* service receives a turn envelope and must return a valid turn-result JSON.
|
|
10
|
+
*
|
|
11
|
+
* v1 scope: synchronous request/response only. No polling, no webhooks.
|
|
12
|
+
*
|
|
13
|
+
* Security: authorization headers are sent to the remote service but are
|
|
14
|
+
* never echoed into dispatch logs or governance artifacts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import {
|
|
20
|
+
getDispatchContextPath,
|
|
21
|
+
getDispatchPromptPath,
|
|
22
|
+
getDispatchTurnDir,
|
|
23
|
+
getTurnStagingDir,
|
|
24
|
+
getTurnStagingResultPath,
|
|
25
|
+
} from '../turn-paths.js';
|
|
26
|
+
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
27
|
+
|
|
28
|
+
/** Default timeout for remote agent requests (ms). */
|
|
29
|
+
export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
|
|
30
|
+
|
|
31
|
+
/** Header keys that must never appear in logs or artifacts. */
|
|
32
|
+
const REDACTED_HEADER_PATTERNS = [/^authorization$/i, /^x-api-key$/i, /^cookie$/i, /^proxy-authorization$/i];
|
|
33
|
+
|
|
34
|
+
function isSecretHeader(key) {
|
|
35
|
+
return REDACTED_HEADER_PATTERNS.some(p => p.test(key));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a safe header summary for logs (redacts secret values).
|
|
40
|
+
*/
|
|
41
|
+
function safeHeaderSummary(headers) {
|
|
42
|
+
if (!headers || typeof headers !== 'object') return {};
|
|
43
|
+
const safe = {};
|
|
44
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
45
|
+
safe[k] = isSecretHeader(k) ? '[REDACTED]' : v;
|
|
46
|
+
}
|
|
47
|
+
return safe;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dispatch a governed turn to a remote agent service.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} root - project root directory
|
|
54
|
+
* @param {object} state - current governed state
|
|
55
|
+
* @param {object} config - normalized config
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @param {AbortSignal} [options.signal] - abort signal
|
|
58
|
+
* @param {function} [options.onStatus] - status callback
|
|
59
|
+
* @param {boolean} [options.verifyManifest] - require dispatch manifest verification
|
|
60
|
+
* @param {boolean} [options.skipManifestVerification] - skip manifest check
|
|
61
|
+
* @param {string} [options.turnId] - override turn ID
|
|
62
|
+
* @returns {Promise<{ ok: boolean, error?: string, logs?: string[], aborted?: boolean, timedOut?: boolean }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function dispatchRemoteAgent(root, state, config, options = {}) {
|
|
65
|
+
const { signal, onStatus, turnId } = options;
|
|
66
|
+
const logs = [];
|
|
67
|
+
|
|
68
|
+
const turn = resolveTargetTurn(state, turnId);
|
|
69
|
+
if (!turn) {
|
|
70
|
+
return { ok: false, error: 'No active turn in state', logs };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
|
|
74
|
+
if (!manifestCheck.ok) {
|
|
75
|
+
return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}`, logs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const runtimeId = turn.runtime_id;
|
|
79
|
+
const runtime = config.runtimes?.[runtimeId];
|
|
80
|
+
if (!runtime || runtime.type !== 'remote_agent') {
|
|
81
|
+
return { ok: false, error: `Runtime "${runtimeId}" is not a remote_agent runtime`, logs };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!runtime.url) {
|
|
85
|
+
return { ok: false, error: `Runtime "${runtimeId}" is missing required "url"`, logs };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
|
|
89
|
+
const contextPath = join(root, getDispatchContextPath(turn.turn_id));
|
|
90
|
+
|
|
91
|
+
if (!existsSync(promptPath)) {
|
|
92
|
+
return { ok: false, error: 'Dispatch bundle not found — PROMPT.md missing', logs };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prompt = readFileSync(promptPath, 'utf8');
|
|
96
|
+
const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
|
|
97
|
+
|
|
98
|
+
// Build request envelope — governed turn metadata + rendered content
|
|
99
|
+
const timeoutMs = runtime.timeout_ms || DEFAULT_REMOTE_AGENT_TIMEOUT_MS;
|
|
100
|
+
const requestBody = {
|
|
101
|
+
run_id: state.run_id,
|
|
102
|
+
turn_id: turn.turn_id,
|
|
103
|
+
role: turn.assigned_role,
|
|
104
|
+
phase: state.phase,
|
|
105
|
+
runtime_id: runtimeId,
|
|
106
|
+
dispatch_dir: join(root, getDispatchTurnDir(turn.turn_id)),
|
|
107
|
+
prompt,
|
|
108
|
+
context,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Ensure staging directory exists
|
|
112
|
+
const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
|
|
113
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
// Log transport metadata (safe — no secrets)
|
|
116
|
+
logs.push(`[remote] POST ${runtime.url}`);
|
|
117
|
+
logs.push(`[remote] timeout_ms=${timeoutMs}`);
|
|
118
|
+
const safeHeaders = safeHeaderSummary(runtime.headers);
|
|
119
|
+
if (Object.keys(safeHeaders).length > 0) {
|
|
120
|
+
logs.push(`[remote] headers=${JSON.stringify(safeHeaders)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onStatus?.(`Posting to remote agent: ${runtime.url}`);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
if (signal?.aborted) {
|
|
127
|
+
return { ok: false, aborted: true, logs };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const headers = {
|
|
131
|
+
'content-type': 'application/json',
|
|
132
|
+
...(runtime.headers || {}),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
137
|
+
|
|
138
|
+
// Chain external abort signal to our controller
|
|
139
|
+
if (signal) {
|
|
140
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetch(runtime.url, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers,
|
|
148
|
+
body: JSON.stringify(requestBody),
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
if (signal?.aborted) {
|
|
154
|
+
return { ok: false, aborted: true, logs };
|
|
155
|
+
}
|
|
156
|
+
if (err.name === 'AbortError' || err.code === 'ABORT_ERR') {
|
|
157
|
+
logs.push(`[remote] Request timed out after ${timeoutMs}ms`);
|
|
158
|
+
return { ok: false, timedOut: true, error: `Remote agent timed out after ${timeoutMs}ms`, logs };
|
|
159
|
+
}
|
|
160
|
+
logs.push(`[remote] Network error: ${err.message}`);
|
|
161
|
+
return { ok: false, error: `Remote agent network error: ${err.message}`, logs };
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timeout);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (signal?.aborted) {
|
|
167
|
+
return { ok: false, aborted: true, logs };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
logs.push(`[remote] HTTP ${response.status} ${response.statusText}`);
|
|
171
|
+
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
let body = '';
|
|
174
|
+
try { body = await response.text(); } catch { /* ignore */ }
|
|
175
|
+
if (body) logs.push(`[remote] Body: ${body.slice(0, 500)}`);
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `Remote agent returned HTTP ${response.status}`,
|
|
179
|
+
logs,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Parse response JSON
|
|
184
|
+
let responseData;
|
|
185
|
+
try {
|
|
186
|
+
responseData = await response.json();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logs.push(`[remote] JSON parse error: ${err.message}`);
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: 'Remote agent response is not valid JSON',
|
|
192
|
+
logs,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
|
|
197
|
+
if (!looksLikeTurnResult(responseData)) {
|
|
198
|
+
logs.push('[remote] Response missing required turn-result fields (need at least run_id/turn_id + status/role)');
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: 'Remote agent response does not contain a valid turn result',
|
|
202
|
+
logs,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Stage the result
|
|
207
|
+
const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
|
|
208
|
+
writeFileSync(stagingPath, JSON.stringify(responseData, null, 2));
|
|
209
|
+
|
|
210
|
+
logs.push(`[remote] Turn result staged at ${getTurnStagingResultPath(turn.turn_id)}`);
|
|
211
|
+
onStatus?.('Remote agent returned valid turn result');
|
|
212
|
+
return { ok: true, logs };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (signal?.aborted) {
|
|
215
|
+
return { ok: false, aborted: true, logs };
|
|
216
|
+
}
|
|
217
|
+
logs.push(`[remote] Unexpected error: ${error.message}`);
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `Remote agent dispatch failed: ${error.message}`,
|
|
221
|
+
logs,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Lightweight structural check: does this object look like a turn result?
|
|
230
|
+
* Full validation happens later via validateStagedTurnResult.
|
|
231
|
+
*/
|
|
232
|
+
function looksLikeTurnResult(value) {
|
|
233
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
234
|
+
const hasIdentity = 'run_id' in value || 'turn_id' in value;
|
|
235
|
+
const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
|
|
236
|
+
return hasIdentity && hasLifecycle;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveTargetTurn(state, turnId) {
|
|
240
|
+
if (turnId && state?.active_turns?.[turnId]) {
|
|
241
|
+
return state.active_turns[turnId];
|
|
242
|
+
}
|
|
243
|
+
return state?.current_turn || Object.values(state?.active_turns || {})[0];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Describe a remote_agent runtime target for display (safe — no secrets).
|
|
248
|
+
*/
|
|
249
|
+
export function describeRemoteAgentTarget(runtime) {
|
|
250
|
+
if (!runtime?.url) return '(unknown)';
|
|
251
|
+
try {
|
|
252
|
+
const u = new URL(runtime.url);
|
|
253
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
254
|
+
} catch {
|
|
255
|
+
return runtime.url;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -210,7 +210,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
210
210
|
lines.push('- You may create/modify files under `.planning/` and `.agentxchain/reviews/`.');
|
|
211
211
|
lines.push('- Your artifact type must be `review`.');
|
|
212
212
|
lines.push('- You MUST raise at least one objection (even if minor).');
|
|
213
|
-
if (runtimeType === 'api_proxy') {
|
|
213
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
214
214
|
const reviewArtifactPath = getReviewArtifactPath(turn.turn_id, roleId);
|
|
215
215
|
lines.push('- **This runtime cannot write repo files directly.** Do NOT claim `.planning/*` or `.agentxchain/reviews/*` changes you did not actually make.');
|
|
216
216
|
lines.push(`- The orchestrator will materialize your accepted review at \`${reviewArtifactPath}\`.`);
|
|
@@ -230,7 +230,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
230
230
|
lines.push('');
|
|
231
231
|
lines.push('- You may propose changes as patches but cannot directly commit.');
|
|
232
232
|
lines.push('- Your artifact type should be `patch`.');
|
|
233
|
-
if (runtimeType === 'api_proxy') {
|
|
233
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
234
234
|
lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
|
|
235
235
|
lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
|
|
236
236
|
lines.push(' ```json');
|
|
@@ -393,7 +393,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
393
393
|
lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
|
|
394
394
|
} else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
|
|
395
395
|
lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
|
|
396
|
-
if (runtimeType === 'api_proxy') {
|
|
396
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
397
397
|
lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
|
|
398
398
|
}
|
|
399
399
|
}
|
|
@@ -408,7 +408,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
408
408
|
lines.push('- **If you found genuine blocking issues that prevent shipping:** set `status: "needs_human"` and explain the blockers in `needs_human_reason`.');
|
|
409
409
|
lines.push('- Do NOT use `status: "needs_human"` to mean "human should approve the release." That is what `run_completion_request: true` is for.');
|
|
410
410
|
lines.push('- Do NOT set `phase_transition_request` to the exit gate name.');
|
|
411
|
-
if (runtimeType === 'api_proxy') {
|
|
411
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
412
412
|
lines.push('- `run_completion_request: true` does **not** mean this runtime wrote `.planning/acceptance-matrix.md`, `.planning/ship-verdict.md`, or `.planning/RELEASE_NOTES.md` for you.');
|
|
413
413
|
}
|
|
414
414
|
}
|
|
@@ -139,7 +139,10 @@ function renderDerivedReviewArtifact(turnResult, state) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline = null) {
|
|
142
|
-
if (
|
|
142
|
+
if (
|
|
143
|
+
turnResult?.artifact?.type !== 'review'
|
|
144
|
+
|| (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
|
|
145
|
+
) {
|
|
143
146
|
return null;
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -156,7 +159,10 @@ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType,
|
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
function materializeDerivedProposalArtifact(root, turnResult, state, runtimeType) {
|
|
159
|
-
if (
|
|
162
|
+
if (
|
|
163
|
+
turnResult?.artifact?.type !== 'patch'
|
|
164
|
+
|| (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
|
|
165
|
+
) {
|
|
160
166
|
return null;
|
|
161
167
|
}
|
|
162
168
|
if (!Array.isArray(turnResult.proposed_changes) || turnResult.proposed_changes.length === 0) {
|
|
@@ -17,7 +17,7 @@ import { validateNotificationsConfig } from './notification-runner.js';
|
|
|
17
17
|
import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
18
18
|
|
|
19
19
|
const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
|
|
20
|
-
const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
|
|
20
|
+
const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
|
|
21
21
|
const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
|
|
22
22
|
export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
|
|
23
23
|
const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
|
|
@@ -157,6 +157,42 @@ function validateMcpRuntime(runtimeId, runtime, errors) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
function validateRemoteAgentRuntime(runtimeId, runtime, errors) {
|
|
161
|
+
// url: required, absolute http(s)
|
|
162
|
+
if (typeof runtime?.url !== 'string' || !runtime.url.trim()) {
|
|
163
|
+
errors.push(`Runtime "${runtimeId}": remote_agent requires "url"`);
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = new URL(runtime.url);
|
|
167
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
168
|
+
errors.push(`Runtime "${runtimeId}": remote_agent url must use http or https`);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
errors.push(`Runtime "${runtimeId}": remote_agent url must be a valid absolute URL`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// headers: optional object of string values
|
|
176
|
+
if ('headers' in runtime) {
|
|
177
|
+
if (!runtime.headers || typeof runtime.headers !== 'object' || Array.isArray(runtime.headers)) {
|
|
178
|
+
errors.push(`Runtime "${runtimeId}": remote_agent headers must be an object of string values`);
|
|
179
|
+
} else {
|
|
180
|
+
for (const [key, value] of Object.entries(runtime.headers)) {
|
|
181
|
+
if (typeof value !== 'string') {
|
|
182
|
+
errors.push(`Runtime "${runtimeId}": remote_agent headers["${key}"] must be a string`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// timeout_ms: optional positive integer
|
|
189
|
+
if ('timeout_ms' in runtime) {
|
|
190
|
+
if (!Number.isInteger(runtime.timeout_ms) || runtime.timeout_ms <= 0) {
|
|
191
|
+
errors.push(`Runtime "${runtimeId}": remote_agent timeout_ms must be a positive integer`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
function validateApiProxyRetryPolicy(runtimeId, retryPolicy, errors) {
|
|
161
197
|
if (!retryPolicy || typeof retryPolicy !== 'object' || Array.isArray(retryPolicy)) {
|
|
162
198
|
errors.push(`Runtime "${runtimeId}": retry_policy must be an object`);
|
|
@@ -391,6 +427,9 @@ export function validateV4Config(data, projectRoot) {
|
|
|
391
427
|
if (rt.type === 'mcp') {
|
|
392
428
|
validateMcpRuntime(id, rt, errors);
|
|
393
429
|
}
|
|
430
|
+
if (rt.type === 'remote_agent') {
|
|
431
|
+
validateRemoteAgentRuntime(id, rt, errors);
|
|
432
|
+
}
|
|
394
433
|
}
|
|
395
434
|
}
|
|
396
435
|
|
|
@@ -412,11 +451,18 @@ export function validateV4Config(data, projectRoot) {
|
|
|
412
451
|
errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
|
|
413
452
|
}
|
|
414
453
|
}
|
|
415
|
-
// api_proxy restriction: only review_only and proposed roles may bind
|
|
454
|
+
// api_proxy and remote_agent restriction: only review_only and proposed roles may bind.
|
|
455
|
+
// These adapters do not have a proven local workspace mutation path in v1.
|
|
416
456
|
if (role.runtime && data.runtimes[role.runtime]) {
|
|
417
457
|
const rt = data.runtimes[role.runtime];
|
|
418
|
-
if (
|
|
419
|
-
|
|
458
|
+
if (
|
|
459
|
+
(rt.type === 'api_proxy' || rt.type === 'remote_agent')
|
|
460
|
+
&& role.write_authority !== 'review_only'
|
|
461
|
+
&& role.write_authority !== 'proposed'
|
|
462
|
+
) {
|
|
463
|
+
errors.push(
|
|
464
|
+
`Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
|
|
465
|
+
);
|
|
420
466
|
}
|
|
421
467
|
}
|
|
422
468
|
}
|
|
@@ -387,14 +387,14 @@ function validateArtifact(tr, config) {
|
|
|
387
387
|
warnings.push('Authoritative role completed with no files_changed — is this intentional?');
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
// Validate proposed_changes for proposed
|
|
390
|
+
// Validate proposed_changes for proposed runtimes that cannot write repo files directly.
|
|
391
391
|
const runtimeType = config.runtimes?.[tr.runtime_id]?.type;
|
|
392
|
-
if (writeAuthority === 'proposed' && runtimeType === 'api_proxy') {
|
|
392
|
+
if (writeAuthority === 'proposed' && (runtimeType === 'api_proxy' || runtimeType === 'remote_agent')) {
|
|
393
393
|
// Completion-request turns are explicitly allowed to have empty proposed_changes —
|
|
394
394
|
// the turn is signaling run completion, not delivering work.
|
|
395
395
|
const isCompletionRequest = tr.run_completion_request === true;
|
|
396
396
|
if (tr.status === 'completed' && (!tr.proposed_changes || tr.proposed_changes.length === 0) && !isCompletionRequest) {
|
|
397
|
-
errors.push(
|
|
397
|
+
errors.push(`Proposed ${runtimeType} turn completed but proposed_changes is empty or missing.`);
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
if (tr.proposed_changes && Array.isArray(tr.proposed_changes)) {
|