agentxchain 2.28.0 → 2.30.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/context-section-parser.js +2 -0
- package/src/lib/dispatch-bundle.js +122 -5
- package/src/lib/governed-state.js +8 -2
- package/src/lib/normalized-config.js +69 -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.30.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
|
+
}
|
|
@@ -10,6 +10,7 @@ const SECTION_DEFINITIONS = [
|
|
|
10
10
|
{ id: 'last_turn_verification', header: null, required: false },
|
|
11
11
|
{ id: 'blockers', header: 'Blockers', required: true },
|
|
12
12
|
{ id: 'escalation', header: 'Escalation', required: true },
|
|
13
|
+
{ id: 'workflow_artifacts', header: 'Workflow Artifacts', required: false },
|
|
13
14
|
{ id: 'gate_required_files', header: 'Gate Required Files', required: false },
|
|
14
15
|
{ id: 'phase_gate_status', header: 'Phase Gate Status', required: false },
|
|
15
16
|
];
|
|
@@ -88,6 +89,7 @@ export function renderContextSections(sections) {
|
|
|
88
89
|
|
|
89
90
|
appendTopLevelSection(lines, 'Blockers', [sectionMap.get('blockers')?.content]);
|
|
90
91
|
appendTopLevelSection(lines, 'Escalation', [sectionMap.get('escalation')?.content]);
|
|
92
|
+
appendTopLevelSection(lines, 'Workflow Artifacts', [sectionMap.get('workflow_artifacts')?.content]);
|
|
91
93
|
appendTopLevelSection(lines, 'Gate Required Files', [sectionMap.get('gate_required_files')?.content]);
|
|
92
94
|
appendTopLevelSection(lines, 'Phase Gate Status', [sectionMap.get('phase_gate_status')?.content]);
|
|
93
95
|
|
|
@@ -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');
|
|
@@ -248,6 +248,31 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
248
248
|
lines.push('');
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
const workflowResponsibilities = getWorkflowPromptResponsibilities(config, phase, roleId, root);
|
|
252
|
+
if (workflowResponsibilities.length > 0) {
|
|
253
|
+
const isReviewOnlyOwner = role.write_authority === 'review_only';
|
|
254
|
+
lines.push('## Workflow-Kit Responsibilities');
|
|
255
|
+
lines.push('');
|
|
256
|
+
if (isReviewOnlyOwner) {
|
|
257
|
+
lines.push(`You are accountable for reviewing and attesting to these workflow-kit artifacts in phase \`${phase}\`:`);
|
|
258
|
+
} else {
|
|
259
|
+
lines.push(`You are accountable for producing these workflow-kit artifacts in phase \`${phase}\`:`);
|
|
260
|
+
}
|
|
261
|
+
lines.push('');
|
|
262
|
+
for (const artifact of workflowResponsibilities) {
|
|
263
|
+
const requiredLabel = artifact.required ? 'required' : 'optional';
|
|
264
|
+
const semanticsLabel = artifact.semantics ? `\`${artifact.semantics}\`` : '—';
|
|
265
|
+
lines.push(`- \`${artifact.path}\` — ${requiredLabel}; semantics: ${semanticsLabel}; status: ${artifact.status}`);
|
|
266
|
+
}
|
|
267
|
+
lines.push('');
|
|
268
|
+
if (isReviewOnlyOwner) {
|
|
269
|
+
lines.push('You cannot write repo files directly. Your accountability means you must confirm these artifacts exist, meet quality standards, and satisfy their semantic requirements. If a required artifact you own is missing, escalate to the producing role — do not request phase transition.');
|
|
270
|
+
} else {
|
|
271
|
+
lines.push('Do not request phase transition or run completion while a required workflow-kit artifact you own is missing or incomplete.');
|
|
272
|
+
}
|
|
273
|
+
lines.push('');
|
|
274
|
+
}
|
|
275
|
+
|
|
251
276
|
// Gate requirements
|
|
252
277
|
if (gateConfig) {
|
|
253
278
|
lines.push('## Phase Exit Gate');
|
|
@@ -393,7 +418,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
393
418
|
lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
|
|
394
419
|
} else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
|
|
395
420
|
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') {
|
|
421
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
397
422
|
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
423
|
}
|
|
399
424
|
}
|
|
@@ -408,7 +433,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
408
433
|
lines.push('- **If you found genuine blocking issues that prevent shipping:** set `status: "needs_human"` and explain the blockers in `needs_human_reason`.');
|
|
409
434
|
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
435
|
lines.push('- Do NOT set `phase_transition_request` to the exit gate name.');
|
|
411
|
-
if (runtimeType === 'api_proxy') {
|
|
436
|
+
if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
|
|
412
437
|
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
438
|
}
|
|
414
439
|
}
|
|
@@ -421,6 +446,47 @@ function renderPrompt(role, roleId, turn, state, config, root) {
|
|
|
421
446
|
};
|
|
422
447
|
}
|
|
423
448
|
|
|
449
|
+
function getWorkflowPromptResponsibilities(config, phase, roleId, root) {
|
|
450
|
+
const artifacts = config?.workflow_kit?.phases?.[phase]?.artifacts;
|
|
451
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const entryRole = config?.routing?.[phase]?.entry_role || null;
|
|
456
|
+
const responsibilities = [];
|
|
457
|
+
|
|
458
|
+
for (const artifact of artifacts) {
|
|
459
|
+
if (!artifact?.path) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const owner = typeof artifact.owned_by === 'string' && artifact.owned_by
|
|
464
|
+
? artifact.owned_by
|
|
465
|
+
: null;
|
|
466
|
+
const responsibleRole = owner || entryRole;
|
|
467
|
+
if (responsibleRole !== roleId) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const absPath = join(root, artifact.path);
|
|
472
|
+
let exists = false;
|
|
473
|
+
try {
|
|
474
|
+
exists = existsSync(absPath);
|
|
475
|
+
} catch {
|
|
476
|
+
exists = false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
responsibilities.push({
|
|
480
|
+
path: artifact.path,
|
|
481
|
+
required: artifact.required !== false,
|
|
482
|
+
semantics: artifact.semantics || null,
|
|
483
|
+
status: exists ? 'exists' : 'MISSING',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return responsibilities;
|
|
488
|
+
}
|
|
489
|
+
|
|
424
490
|
// ── Context Rendering ───────────────────────────────────────────────────────
|
|
425
491
|
|
|
426
492
|
function renderContext(state, config, root, turn, role) {
|
|
@@ -621,8 +687,59 @@ function renderContext(state, config, root, turn, role) {
|
|
|
621
687
|
lines.push('');
|
|
622
688
|
}
|
|
623
689
|
|
|
624
|
-
//
|
|
690
|
+
// Workflow-kit artifacts for the current phase
|
|
625
691
|
const phase = state.phase;
|
|
692
|
+
const wkArtifacts = config?.workflow_kit?.phases?.[phase]?.artifacts;
|
|
693
|
+
if (Array.isArray(wkArtifacts) && wkArtifacts.length > 0) {
|
|
694
|
+
lines.push('## Workflow Artifacts');
|
|
695
|
+
lines.push('');
|
|
696
|
+
lines.push(`Current phase **${phase}** declares the following artifacts:`);
|
|
697
|
+
lines.push('');
|
|
698
|
+
lines.push('| Artifact | Required | Semantics | Owner | Status |');
|
|
699
|
+
lines.push('|----------|----------|-----------|-------|--------|');
|
|
700
|
+
const isReviewRole = role?.write_authority === 'review_only';
|
|
701
|
+
const reviewPreviews = [];
|
|
702
|
+
for (const art of wkArtifacts) {
|
|
703
|
+
if (!art?.path) continue;
|
|
704
|
+
const absPath = join(root, art.path);
|
|
705
|
+
let exists = false;
|
|
706
|
+
try { exists = existsSync(absPath); } catch { /* treat as missing */ }
|
|
707
|
+
const req = art.required !== false ? 'yes' : 'no';
|
|
708
|
+
const owner = art.owned_by || '—';
|
|
709
|
+
const status = exists ? 'exists' : 'MISSING';
|
|
710
|
+
const semCol = art.semantics ? `\`${art.semantics}\`` : '—';
|
|
711
|
+
lines.push(`| \`${art.path}\` | ${req} | ${semCol} | ${owner} | ${status} |`);
|
|
712
|
+
if (isReviewRole && exists) {
|
|
713
|
+
reviewPreviews.push(art);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
lines.push('');
|
|
717
|
+
if (reviewPreviews.length > 0) {
|
|
718
|
+
for (const art of reviewPreviews) {
|
|
719
|
+
const absPath = join(root, art.path);
|
|
720
|
+
const preview = buildGateFilePreview(absPath);
|
|
721
|
+
if (preview) {
|
|
722
|
+
lines.push(`### \`${art.path}\``);
|
|
723
|
+
lines.push('');
|
|
724
|
+
const semantic = extractGateFileSemantic(art.path, preview.raw);
|
|
725
|
+
if (semantic) {
|
|
726
|
+
lines.push(`**Semantic: ${semantic}**`);
|
|
727
|
+
lines.push('');
|
|
728
|
+
}
|
|
729
|
+
lines.push('```');
|
|
730
|
+
lines.push(preview.content);
|
|
731
|
+
lines.push('```');
|
|
732
|
+
if (preview.truncated) {
|
|
733
|
+
lines.push('');
|
|
734
|
+
lines.push(`_Preview truncated after ${GATE_FILE_PREVIEW_MAX_LINES} lines._`);
|
|
735
|
+
}
|
|
736
|
+
lines.push('');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Phase gate requirements
|
|
626
743
|
const routing = config.routing?.[phase];
|
|
627
744
|
const exitGate = routing?.exit_gate;
|
|
628
745
|
const gateConfig = exitGate ? config.gates?.[exitGate] : null;
|
|
@@ -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
|
}
|
|
@@ -582,6 +628,25 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
|
|
|
582
628
|
errors.push(`${prefix} owned_by "${artifact.owned_by}" is not a valid role ID (must be lowercase alphanumeric with hyphens/underscores)`);
|
|
583
629
|
} else if (roles && typeof roles === 'object' && !roles[artifact.owned_by]) {
|
|
584
630
|
errors.push(`${prefix} owned_by "${artifact.owned_by}" does not reference a defined role`);
|
|
631
|
+
} else if (
|
|
632
|
+
artifact.required !== false &&
|
|
633
|
+
roles && typeof roles === 'object' &&
|
|
634
|
+
roles[artifact.owned_by]?.write_authority === 'review_only'
|
|
635
|
+
) {
|
|
636
|
+
// Check if any authoritative/proposed role exists in this phase's routing
|
|
637
|
+
const phaseRouting = routing?.[phase];
|
|
638
|
+
const phaseRoles = new Set([
|
|
639
|
+
...(phaseRouting?.allowed_next_roles || []),
|
|
640
|
+
...(phaseRouting?.entry_role ? [phaseRouting.entry_role] : []),
|
|
641
|
+
]);
|
|
642
|
+
const hasWriter = [...phaseRoles].some(rid =>
|
|
643
|
+
roles[rid]?.write_authority === 'authoritative' || roles[rid]?.write_authority === 'proposed',
|
|
644
|
+
);
|
|
645
|
+
if (!hasWriter) {
|
|
646
|
+
warnings.push(
|
|
647
|
+
`${prefix} owned_by "${artifact.owned_by}" is a review_only role in phase "${phase}" with no authoritative or proposed role — nobody can write this required artifact`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
585
650
|
}
|
|
586
651
|
}
|
|
587
652
|
}
|
|
@@ -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)) {
|