@remits/remits-cli 0.1.157 → 0.1.159
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 +8 -0
- package/index.js +182 -31
- package/lib/local-ai.js +465 -0
- package/package.json +2 -1
- package/skills/remits-cli/SKILL.md +2 -0
- package/skills/remits-cli/references/ai-quality.md +17 -7
- package/skills/remits-cli/references/command-reference.md +19 -9
- package/skills/remits-cli/references/development-loop.md +33 -5
- package/skills/remits-cli/references/local-ai.md +111 -0
package/README.md
CHANGED
|
@@ -49,6 +49,14 @@ remits-cli config set --agent codex
|
|
|
49
49
|
remits-cli activity inspect --account-id 4 --scope children --data-mode test
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
## Local AI inference
|
|
53
|
+
|
|
54
|
+
Run `remits-cli ai serve --agent codex --account-id 123 --base-url http://localhost:8080` in a terminal,
|
|
55
|
+
then opt in with `ai(localAgent:true, ...)`. Claude, Gemini, and manual workers are also supported.
|
|
56
|
+
The supervisor resumes one native conversation per Remits session; workflow tools continue executing
|
|
57
|
+
on the server. The data lane defaults to test. See
|
|
58
|
+
[local inference commands and limits](skills/remits-cli/references/local-ai.md).
|
|
59
|
+
|
|
52
60
|
## Skill Install
|
|
53
61
|
|
|
54
62
|
```bash
|
package/index.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
- L4591 Auth And Component Staging
|
|
13
13
|
- L5223 Component Summaries, Status, And Sync Gates
|
|
14
14
|
- L7547 Branches, Promotion, Commit, And Test Runs
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
15
|
+
- L9790 Tokens, Tools, Verification, And Config
|
|
16
|
+
- L11307 Service Dashboard And WebSocket Listener
|
|
17
|
+
- L13806 Agent And Ticket Workflows
|
|
18
|
+
- L17982 Help, Auto Update, And Command Dispatch
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
/*
|
|
@@ -112,7 +112,7 @@ let LOCAL_ACTOR_CONTEXT = null;
|
|
|
112
112
|
|
|
113
113
|
// Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
|
|
114
114
|
// flag keeps last-wins so existing callers are unaffected.
|
|
115
|
-
const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names', 'tag', 'tags', 'key', 'keys', 'case-key', 'score', 'scores', 'lower-is-better']);
|
|
115
|
+
const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved', 'names', 'tag', 'tags', 'key', 'keys', 'case-key', 'score', 'scores', 'lower-is-better', 'param', 'params']);
|
|
116
116
|
|
|
117
117
|
// `-m "msg"` is how git spells a commit message, and agents type it from habit. Before this the parser took a
|
|
118
118
|
// single-dash token as the VALUE of whatever flag preceded it: `--json -m "x"` silently turned JSON mode
|
|
@@ -9156,7 +9156,9 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
|
|
|
9156
9156
|
const data = JSON.parse(msg.body);
|
|
9157
9157
|
if (data.type === 'TestSuite') {
|
|
9158
9158
|
const payload = data.payload || {};
|
|
9159
|
-
|
|
9159
|
+
// A parallel run is followed by every shard's task id.
|
|
9160
|
+
const followed = Array.isArray(taskId) ? taskId : (taskId ? [taskId] : []);
|
|
9161
|
+
if (payload.taskId && followed.length && !followed.includes(payload.taskId)) {
|
|
9160
9162
|
return;
|
|
9161
9163
|
}
|
|
9162
9164
|
if (payload.tests || payload.name || payload.total != null) {
|
|
@@ -9179,6 +9181,82 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
|
|
|
9179
9181
|
};
|
|
9180
9182
|
}
|
|
9181
9183
|
|
|
9184
|
+
// `--param name=value` (repeatable) -> the map a suite reads with runParams(). A value that is a JSON number,
|
|
9185
|
+
// boolean, null, array or object is passed as that JSON; anything else is the literal text, so `--param model=x`
|
|
9186
|
+
// needs no quoting. A name without `=` is refused rather than guessed at.
|
|
9187
|
+
function parseRunParams(raw) {
|
|
9188
|
+
const values = raw === undefined || raw === null || raw === true ? [] : (Array.isArray(raw) ? raw : [raw]);
|
|
9189
|
+
const params = {};
|
|
9190
|
+
values.forEach((entry) => {
|
|
9191
|
+
const text = String(entry);
|
|
9192
|
+
const eq = text.indexOf('=');
|
|
9193
|
+
if (eq <= 0) throw new Error('--param expects name=value (got "' + text + '")');
|
|
9194
|
+
const name = text.slice(0, eq).trim();
|
|
9195
|
+
const value = text.slice(eq + 1);
|
|
9196
|
+
let parsed = value;
|
|
9197
|
+
const trimmed = value.trim();
|
|
9198
|
+
if (/^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)$/.test(trimmed) || /^(\[[\s\S]*\]|\{[\s\S]*\})$/.test(trimmed)) {
|
|
9199
|
+
try {
|
|
9200
|
+
parsed = JSON.parse(trimmed);
|
|
9201
|
+
} catch (err) {
|
|
9202
|
+
parsed = value;
|
|
9203
|
+
}
|
|
9204
|
+
}
|
|
9205
|
+
params[name] = parsed;
|
|
9206
|
+
});
|
|
9207
|
+
return params;
|
|
9208
|
+
}
|
|
9209
|
+
|
|
9210
|
+
// How `test run` labels, parameterises, repeats and shards a run, from its flags. Validation of the values is the
|
|
9211
|
+
// platform's (Test.normalizeRunOptions); this only shapes the request and checks what the CLI itself acts on.
|
|
9212
|
+
function testRunOptionsFromFlags(flags = {}) {
|
|
9213
|
+
const label = flags.label === undefined || flags.label === true ? undefined : String(flags.label);
|
|
9214
|
+
const params = parseRunParams(flags.param !== undefined ? flags.param : flags.params);
|
|
9215
|
+
const repeat = flags.repeat === undefined ? undefined : parsePositiveInt(flags.repeat, NaN);
|
|
9216
|
+
if (flags.repeat !== undefined && !Number.isFinite(repeat)) throw new Error('--repeat expects a whole number of runs per case (got "' + flags.repeat + '")');
|
|
9217
|
+
const parallel = flags.parallel === undefined ? 1 : parsePositiveInt(flags.parallel, NaN);
|
|
9218
|
+
if (!Number.isFinite(parallel)) throw new Error('--parallel expects a whole number of shards (got "' + flags.parallel + '")');
|
|
9219
|
+
return {
|
|
9220
|
+
label,
|
|
9221
|
+
params: Object.keys(params).length ? params : undefined,
|
|
9222
|
+
repeat: repeat && repeat > 1 ? repeat : undefined,
|
|
9223
|
+
parallel
|
|
9224
|
+
};
|
|
9225
|
+
}
|
|
9226
|
+
|
|
9227
|
+
// How long a later shard of a parallel run waits for a free suite slot before the launch gives up.
|
|
9228
|
+
const SHARD_ADMISSION_WAIT_MS = 30 * 60 * 1000;
|
|
9229
|
+
|
|
9230
|
+
// A start the platform refused because every suite slot on the instance is taken (safe to repeat: no work began).
|
|
9231
|
+
function shardStartBusy(err) {
|
|
9232
|
+
const response = err && err.response;
|
|
9233
|
+
return !!(response && response.status === 429 && response.data && response.data.code === 'WORKLOAD_BUSY');
|
|
9234
|
+
}
|
|
9235
|
+
|
|
9236
|
+
// One start request per shard of a `--parallel N` run, all under one group id: shard i of N runs every Nth
|
|
9237
|
+
// selected case. The platform reads the group id as ONE run in status, corpus runs/compare/gate.
|
|
9238
|
+
function shardRunPayloads(runOptions = {}, groupId) {
|
|
9239
|
+
const count = Math.max(1, Number(runOptions.parallel || 1));
|
|
9240
|
+
const common = { label: runOptions.label, params: runOptions.params, repeat: runOptions.repeat };
|
|
9241
|
+
if (count <= 1) return [common];
|
|
9242
|
+
return Array.from({ length: count }, (_, i) => Object.assign({}, common, {
|
|
9243
|
+
runGroupId: groupId,
|
|
9244
|
+
shard: { index: i + 1, count }
|
|
9245
|
+
}));
|
|
9246
|
+
}
|
|
9247
|
+
|
|
9248
|
+
// What a launch asked for beyond the suite itself: label, params, repetitions and shards.
|
|
9249
|
+
function testRunLaunchLines(start = {}, runOptions = {}) {
|
|
9250
|
+
const lines = [];
|
|
9251
|
+
if (runOptions.label) lines.push('Label: ' + runOptions.label);
|
|
9252
|
+
if (runOptions.params) lines.push('Params: ' + Object.keys(runOptions.params).map((k) => k + '=' + JSON.stringify(runOptions.params[k])).join(' '));
|
|
9253
|
+
if (runOptions.repeat) lines.push('Repeat: every selected case runs ' + runOptions.repeat + ' times');
|
|
9254
|
+
if (start.runGroupId) {
|
|
9255
|
+
lines.push('Parallel: ' + (start.shardTaskIds || []).length + ' shards (' + (start.shardTaskIds || []).join(', ') + '), read as ONE run by ' + start.runGroupId);
|
|
9256
|
+
}
|
|
9257
|
+
return lines;
|
|
9258
|
+
}
|
|
9259
|
+
|
|
9182
9260
|
async function testCommand(flags) {
|
|
9183
9261
|
const cwd = process.cwd();
|
|
9184
9262
|
ensureLocalState(cwd);
|
|
@@ -9207,6 +9285,9 @@ async function testCommand(flags) {
|
|
|
9207
9285
|
? []
|
|
9208
9286
|
: (Array.isArray(flags.names) ? flags.names : [flags.names]);
|
|
9209
9287
|
const names = parseTestCaseSelectors(rawNames);
|
|
9288
|
+
// --label / --param / --repeat / --parallel: what this run varies, and how it is executed.
|
|
9289
|
+
const runOptions = testRunOptionsFromFlags(flags);
|
|
9290
|
+
const runGroupId = runOptions.parallel > 1 ? 'grp-' + crypto.randomUUID() : undefined;
|
|
9210
9291
|
|
|
9211
9292
|
const api = buildAxios(baseUrl, session.token);
|
|
9212
9293
|
// --as-account runs the Test AS a (descendant) subscriber account, so that account's relationship
|
|
@@ -9243,7 +9324,7 @@ async function testCommand(flags) {
|
|
|
9243
9324
|
});
|
|
9244
9325
|
});
|
|
9245
9326
|
|
|
9246
|
-
const
|
|
9327
|
+
const startRequest = {
|
|
9247
9328
|
token: session.token,
|
|
9248
9329
|
accountId,
|
|
9249
9330
|
asAccountId,
|
|
@@ -9255,11 +9336,43 @@ async function testCommand(flags) {
|
|
|
9255
9336
|
testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
|
|
9256
9337
|
testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
|
|
9257
9338
|
tests: names
|
|
9258
|
-
}
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9339
|
+
};
|
|
9340
|
+
// Shards are started one after another (each start returns at once) and then run side by side on the platform.
|
|
9341
|
+
const shardStarts = [];
|
|
9342
|
+
// The platform admits only a few suites per instance at once and refuses the rest as WORKLOAD_BUSY. Once one
|
|
9343
|
+
// of our shards is running it holds a slot it will give back, so a later shard keeps waiting for one (bounded)
|
|
9344
|
+
// instead of giving up after the usual retries and leaving a group that can never finish.
|
|
9345
|
+
const shardWaitDeadline = Date.now() + SHARD_ADMISSION_WAIT_MS;
|
|
9346
|
+
for (const shardOptions of shardRunPayloads(runOptions, runGroupId)) {
|
|
9347
|
+
let shardStart;
|
|
9348
|
+
for (;;) {
|
|
9349
|
+
try {
|
|
9350
|
+
shardStart = await loggedPost(api, cwd, '/cli/test', Object.assign({}, startRequest, shardOptions)).then((r) => r.data);
|
|
9351
|
+
break;
|
|
9352
|
+
} catch (err) {
|
|
9353
|
+
const data = err && err.response && err.response.data;
|
|
9354
|
+
if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
|
|
9355
|
+
console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
|
|
9356
|
+
'(this platform instance runs a limited number of suites at once; the shards already running free them).');
|
|
9357
|
+
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
9358
|
+
continue;
|
|
9359
|
+
}
|
|
9360
|
+
const started = shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
|
|
9361
|
+
' never started; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : '';
|
|
9362
|
+
if (data && data.message) throw new Error('Failed to start test run: ' + data.message + started);
|
|
9363
|
+
throw err;
|
|
9364
|
+
}
|
|
9365
|
+
}
|
|
9366
|
+
if (!shardStart.success) {
|
|
9367
|
+
throw new Error('Failed to start test run' + (shardStarts.length ? ' (shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel +
|
|
9368
|
+
'; the shards already started keep running: ' + shardStarts.map((st) => st.taskId).join(', ') + ')' : ''));
|
|
9369
|
+
}
|
|
9370
|
+
shardStarts.push(shardStart);
|
|
9262
9371
|
}
|
|
9372
|
+
// One run to follow: the single run, or the group whose status the platform merges from every shard.
|
|
9373
|
+
const start = runGroupId
|
|
9374
|
+
? Object.assign({}, shardStarts[0], { taskId: runGroupId, runGroupId, shardTaskIds: shardStarts.map((s) => s.taskId) })
|
|
9375
|
+
: shardStarts[0];
|
|
9263
9376
|
|
|
9264
9377
|
withStdoutRoutedToStderr(jsonOutput, () => {
|
|
9265
9378
|
printSessionResolutionWarning(sessionContext);
|
|
@@ -9281,6 +9394,7 @@ async function testCommand(flags) {
|
|
|
9281
9394
|
gitHead: safeGitValue(cwd, 'git rev-parse HEAD')
|
|
9282
9395
|
}).forEach((line) => console.log(line));
|
|
9283
9396
|
console.log('Test run started:', start.taskId);
|
|
9397
|
+
testRunLaunchLines(start, runOptions).forEach((line) => console.log(line));
|
|
9284
9398
|
});
|
|
9285
9399
|
runtimeState.currentTestTaskId = start.taskId;
|
|
9286
9400
|
|
|
@@ -9330,7 +9444,7 @@ async function testCommand(flags) {
|
|
|
9330
9444
|
if (flags.watch !== 'false') {
|
|
9331
9445
|
const topic = session.websocketTopic || start.websocketTopic || (session.user && String(session.user.uuid || '').replace(/-/g, ''));
|
|
9332
9446
|
if (topic) {
|
|
9333
|
-
stopWs = watchWebsocket(baseUrl, topic, start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
|
|
9447
|
+
stopWs = watchWebsocket(baseUrl, topic, start.shardTaskIds || start.taskId, { stderr: jsonOutput, verbose: flagEnabled(flags.verbose) });
|
|
9334
9448
|
}
|
|
9335
9449
|
}
|
|
9336
9450
|
|
|
@@ -10345,6 +10459,7 @@ function corpusAiLabel(row = {}) {
|
|
|
10345
10459
|
|
|
10346
10460
|
function corpusScoreSummary(scores = {}) {
|
|
10347
10461
|
return Object.keys(scores).map((k) => k + ' ' + (scores[k] && scores[k].mean !== undefined ? scores[k].mean : scores[k]) +
|
|
10462
|
+
(scores[k] && scores[k].stderr !== undefined ? ' \u00b1' + scores[k].stderr : '') +
|
|
10348
10463
|
(scores[k] && scores[k].cases !== undefined ? '/' + scores[k].cases : '')).join(', ');
|
|
10349
10464
|
}
|
|
10350
10465
|
|
|
@@ -10352,7 +10467,8 @@ function corpusScoreDeltas(scores = {}) {
|
|
|
10352
10467
|
return Object.keys(scores).map((k) => {
|
|
10353
10468
|
const s = scores[k] || {};
|
|
10354
10469
|
const delta = s.delta !== undefined && s.delta !== null ? ' (' + (Number(s.delta) >= 0 ? '+' : '') + s.delta + ')' : '';
|
|
10355
|
-
|
|
10470
|
+
const noise = s.stderr !== undefined ? ' [se ' + s.stderr + (s.withinNoise ? ', within noise' : '') + ']' : '';
|
|
10471
|
+
return k + ' ' + (s.base !== undefined ? s.base : '?') + ' -> ' + (s.head !== undefined ? s.head : '?') + delta + noise + (s.regressed ? ' REGRESSED' : '');
|
|
10356
10472
|
}).join(', ');
|
|
10357
10473
|
}
|
|
10358
10474
|
|
|
@@ -10364,16 +10480,22 @@ function corpusLines(sub, data = {}) {
|
|
|
10364
10480
|
cases.forEach((c) => lines.push('- ' + c.caseKey + (c.split ? ' [' + c.split + ']' : '') + (c.tags && c.tags.length ? ' tags ' + c.tags.join(',') : '') +
|
|
10365
10481
|
' expected ' + (c.hasExpected ? (c.expectedStatus || 'present') : 'none') + ' artifacts ' + (c.artifacts || []).map((a) => a.name + ':' + a.bytes + 'b').join(' ')));
|
|
10366
10482
|
} else if (sub === 'results') {
|
|
10367
|
-
(data.results || []).forEach((r) => lines.push('- ' + r.caseKey + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
|
|
10368
|
-
' run ' + r.taskId + ' ' + corpusAiLabel(r) + (r.durationMs ? ' ' + formatDurationMs(r.durationMs) : '') +
|
|
10483
|
+
(data.results || []).forEach((r) => lines.push('- ' + r.caseKey + (r.repeatIndex ? ' #' + r.repeatIndex : '') + ' ' + (r.outcome || '?') + (r.score !== undefined ? ' score ' + r.score : '') +
|
|
10484
|
+
' run ' + (r.runGroupId || r.taskId) + (r.runLabel ? ' (' + r.runLabel + ')' : '') + (r.split ? ' [' + r.split + ']' : '') + ' ' + corpusAiLabel(r) + (r.durationMs ? ' ' + formatDurationMs(r.durationMs) : '') +
|
|
10369
10485
|
' metrics ' + String(r.metricsHash || '').slice(0, 10) + ' ' + formatTime(r.recordedAt)));
|
|
10370
10486
|
} else if (sub === 'runs') {
|
|
10371
|
-
(data.runs || []).forEach((r) => lines.push('- ' + r.taskId + ' ' + r.
|
|
10487
|
+
(data.runs || []).forEach((r) => lines.push('- ' + r.taskId + (r.label ? ' "' + r.label + '"' : '') + ' ' + r.cases + ' case(s)' +
|
|
10488
|
+
(r.repeats ? ' x' + r.repeats + ' repeats' : '') + (r.shards ? ' ' + r.shards + ' shards' : '') + ' ' +
|
|
10489
|
+
Object.keys(r.outcomes || {}).map((k) => k + ' ' + r.outcomes[k]).join(', ') + (r.passRate !== undefined ? ' pass rate ' + r.passRate : '') +
|
|
10372
10490
|
' ' + corpusAiLabel(r) + ' ' + (r.dataMode || '?') + ' ' + (r.branchName || '?') + (r.workspace ? ' [ws:' + r.workspace + ']' : '') +
|
|
10373
10491
|
(r.laneContentHash ? ' lane ' + String(r.laneContentHash).slice(0, 12) : '') + ' ' + formatTime(r.recordedAt)));
|
|
10374
10492
|
(data.runs || []).forEach((r) => {
|
|
10375
10493
|
const extra = [];
|
|
10494
|
+
if (r.params && Object.keys(r.params).length) extra.push('params ' + Object.keys(r.params).map((k) => k + '=' + JSON.stringify(r.params[k])).join(' '));
|
|
10376
10495
|
if (r.scores && Object.keys(r.scores).length) extra.push('scores ' + corpusScoreSummary(r.scores));
|
|
10496
|
+
if (r.bySplit) extra.push('by split ' + Object.keys(r.bySplit).map((sp) => sp + ': ' + r.bySplit[sp].cases + ' case(s)' +
|
|
10497
|
+
(r.bySplit[sp].passRate !== undefined ? ' pass ' + r.bySplit[sp].passRate : '') +
|
|
10498
|
+
(r.bySplit[sp].scores ? ' ' + corpusScoreSummary(r.bySplit[sp].scores) : '')).join('; '));
|
|
10377
10499
|
if (r.review) extra.push('graded ' + r.review.graded + ', spot-checked ' + r.review.reviewed + (r.review.agreement !== undefined ? ' (agreement ' + r.review.agreement + ')' : ''));
|
|
10378
10500
|
if (extra.length) lines.push(' ' + r.taskId + ': ' + extra.join(' '));
|
|
10379
10501
|
});
|
|
@@ -10388,6 +10510,7 @@ function corpusLines(sub, data = {}) {
|
|
|
10388
10510
|
if (cmp.scores && Object.keys(cmp.scores).length) lines.push('Scores (cases both runs measured): ' + corpusScoreDeltas(cmp.scores));
|
|
10389
10511
|
(cmp.cases || []).filter((c) => c.direction !== 'same' || c.changedScores).forEach((c) => {
|
|
10390
10512
|
lines.push('- ' + c.caseKey + ' ' + c.direction + (c.base ? ' ' + c.base.outcome : '') + (c.head ? ' -> ' + c.head.outcome : '') +
|
|
10513
|
+
(c.base && c.base.passRate !== undefined ? ' pass ' + c.base.passRate + ' -> ' + (c.head && c.head.passRate !== undefined ? c.head.passRate : '?') : '') +
|
|
10391
10514
|
(c.expectedChanged ? ' (EXPECTED ANSWER CHANGED between runs)' : ''));
|
|
10392
10515
|
(c.changedMetrics || []).slice(0, 8).forEach((m) => lines.push(' ' + m.metric + ': ' + JSON.stringify(m.base) + ' -> ' + JSON.stringify(m.head)));
|
|
10393
10516
|
Object.keys(c.changedScores || {}).slice(0, 8).forEach((k) => lines.push(' score ' + k + ': ' + c.changedScores[k].base + ' -> ' + c.changedScores[k].head));
|
|
@@ -10404,9 +10527,11 @@ function corpusLines(sub, data = {}) {
|
|
|
10404
10527
|
} else if (sub === 'gate') {
|
|
10405
10528
|
const gate = data.gate || {};
|
|
10406
10529
|
lines.push('Gate ' + (gate.baseTaskId || '?') + ' -> ' + (gate.headTaskId || '?') + ': ' + (gate.passed ? 'PASSED' : 'DID NOT PASS') +
|
|
10407
|
-
(gate.tolerance !== undefined ? ' (tolerance ' + gate.tolerance + ')' : ''))
|
|
10530
|
+
(gate.tolerance !== undefined ? ' (tolerance ' + gate.tolerance + ')' : '') + (gate.noiseSigma !== undefined ? ' (noise sigma ' + gate.noiseSigma + ')' : '') +
|
|
10531
|
+
(gate.split ? ' split ' + gate.split : ''));
|
|
10408
10532
|
if (gate.scores && Object.keys(gate.scores).length) lines.push('Scores: ' + corpusScoreDeltas(gate.scores));
|
|
10409
10533
|
(gate.reasons || []).forEach((r) => lines.push('- ' + r));
|
|
10534
|
+
(gate.notes || []).forEach((n) => lines.push('Note: ' + n));
|
|
10410
10535
|
if (gate.expectedChanged && gate.expectedChanged.length) lines.push('Left out (expected answer changed between runs): ' + gate.expectedChanged.join(', '));
|
|
10411
10536
|
(gate.changedCases || []).slice(0, 15).forEach((c) => lines.push(' ' + c.caseKey + ' ' + c.direction +
|
|
10412
10537
|
Object.keys(c.changedScores || {}).map((k) => ' ' + k + ' ' + c.changedScores[k].base + '->' + c.changedScores[k].head).join('')));
|
|
@@ -10428,7 +10553,7 @@ function corpusLines(sub, data = {}) {
|
|
|
10428
10553
|
lines.push('Case ' + con.caseKey + ': ' + con.runs + ' run(s), ' + (con.stable ? 'STABLE (identical metrics every run)' : (con.variants || []).length + ' distinct metric results') +
|
|
10429
10554
|
(con.aiModes && con.aiModes.length ? ' AI ' + con.aiModes.join('+') : ''));
|
|
10430
10555
|
if (con.note) lines.push('WARNING: ' + con.note);
|
|
10431
|
-
(con.variants || []).forEach((v) => lines.push('- metrics ' + String(v.metricsHash).slice(0, 10) + ' runs ' + (v.runs || []).map((r) => r.taskId + ':' + r.outcome + (r.aiMode && r.aiMode !== 'none' ? '/' + r.aiMode : '')).join(', ')));
|
|
10556
|
+
(con.variants || []).forEach((v) => lines.push('- metrics ' + String(v.metricsHash).slice(0, 10) + ' runs ' + (v.runs || []).map((r) => r.taskId + (r.repeatIndex ? '#' + r.repeatIndex : '') + ':' + r.outcome + (r.aiMode && r.aiMode !== 'none' ? '/' + r.aiMode : '')).join(', ')));
|
|
10432
10557
|
}
|
|
10433
10558
|
return lines;
|
|
10434
10559
|
}
|
|
@@ -10538,12 +10663,16 @@ async function corpusCommand(flags, subcommand) {
|
|
|
10538
10663
|
const keys = parseCorpusFilterValues(flags, ['key', 'keys', 'case', 'case-key']);
|
|
10539
10664
|
data = await post({ command: 'cases', corpus: corpusName, split: flags.split, tags: tags.length ? tags : undefined, keys: keys.length ? keys : undefined, includeValues: flagEnabled(flags['include-values']), includeRetired: flagEnabled(flags['include-retired']), limit: flags.limit });
|
|
10540
10665
|
} else if (sub === 'results') {
|
|
10541
|
-
|
|
10666
|
+
const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
|
|
10667
|
+
data = await post({ command: 'results', corpus: corpusName, caseKey: flags.case || flags['case-key'], taskId: flags['task-id'], label: flags.label,
|
|
10668
|
+
split: flags.split, tags: tags.length ? tags : undefined, limit: flags.limit || 50 });
|
|
10542
10669
|
} else if (sub === 'runs') {
|
|
10543
|
-
|
|
10670
|
+
const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
|
|
10671
|
+
data = await post({ command: 'runs', corpus: corpusName, label: flags.label, split: flags.split, tags: tags.length ? tags : undefined, limit: flags.limit || 20 });
|
|
10544
10672
|
} else if (sub === 'compare') {
|
|
10545
10673
|
if (!flags.base || !flags.head) throw new Error('corpus compare requires --base <taskId> --head <taskId>');
|
|
10546
|
-
|
|
10674
|
+
const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
|
|
10675
|
+
data = await post({ command: 'compare', corpus: corpusName, base: flags.base, head: flags.head, split: flags.split, tags: tags.length ? tags : undefined });
|
|
10547
10676
|
} else if (sub === 'consistency') {
|
|
10548
10677
|
const caseKey = flags.case || flags['case-key'];
|
|
10549
10678
|
if (!caseKey) throw new Error('corpus consistency requires --case <caseKey>');
|
|
@@ -10556,10 +10685,13 @@ async function corpusCommand(flags, subcommand) {
|
|
|
10556
10685
|
} else if (sub === 'gate') {
|
|
10557
10686
|
const scores = parseCorpusFilterValues(flags, ['score', 'scores']);
|
|
10558
10687
|
const lowerIsBetter = parseCorpusFilterValues(flags, ['lower-is-better']);
|
|
10688
|
+
const tags = parseCorpusFilterValues(flags, ['tag', 'tags']);
|
|
10559
10689
|
data = await post({ command: 'gate', corpus: corpusName, base: flags.base, head: flags.head, tolerance: flags.tolerance,
|
|
10560
10690
|
scores: scores.length ? scores : undefined,
|
|
10561
10691
|
lowerIsBetter: lowerIsBetter.length ? lowerIsBetter : undefined,
|
|
10562
|
-
allowModeChange: flagEnabled(flags['allow-mode-change'])
|
|
10692
|
+
allowModeChange: flagEnabled(flags['allow-mode-change']),
|
|
10693
|
+
baseLabel: flags['base-label'], headLabel: flags['head-label'], split: flags.split, tags: tags.length ? tags : undefined,
|
|
10694
|
+
minCases: flags['min-cases'], noiseSigma: flags['noise-sigma'] });
|
|
10563
10695
|
const gate = (data && data.gate) || {};
|
|
10564
10696
|
if (!gate.passed) process.exitCode = 1;
|
|
10565
10697
|
await appendVerificationPacket(api, cwd, session, accountId, flags, {
|
|
@@ -10569,7 +10701,8 @@ async function corpusCommand(flags, subcommand) {
|
|
|
10569
10701
|
world: Object.assign({}, (data && data.world) || {}, { host: normalizeBaseUrl(baseUrl), branchName, workspace, dataMode }),
|
|
10570
10702
|
revision: collectVerificationSource(cwd, flags),
|
|
10571
10703
|
evidenceCategories: ['corpus_gate'],
|
|
10572
|
-
corpusGate: { corpus: corpusName, baseTaskId: gate.baseTaskId, headTaskId: gate.headTaskId, passed: !!gate.passed, scores: gate.scores, reasons: gate.reasons
|
|
10704
|
+
corpusGate: { corpus: corpusName, baseTaskId: gate.baseTaskId, headTaskId: gate.headTaskId, passed: !!gate.passed, scores: gate.scores, reasons: gate.reasons,
|
|
10705
|
+
split: gate.split, noiseSigma: gate.noiseSigma, minCases: gate.minCases, notes: gate.notes }
|
|
10573
10706
|
}, { quiet: jsonOutput });
|
|
10574
10707
|
} else if (sub === 'review') {
|
|
10575
10708
|
const taskId = flags['task-id'];
|
|
@@ -10577,7 +10710,7 @@ async function corpusCommand(flags, subcommand) {
|
|
|
10577
10710
|
if (flags.verdict) {
|
|
10578
10711
|
const caseKey = flags.case || flags['case-key'];
|
|
10579
10712
|
if (!caseKey) throw new Error('corpus review --verdict requires --case <caseKey>');
|
|
10580
|
-
data = await post({ command: 'review', corpus: corpusName, taskId, caseKey, verdict: flags.verdict, score: flags.score, note: flags.note });
|
|
10713
|
+
data = await post({ command: 'review', corpus: corpusName, taskId, caseKey, verdict: flags.verdict, score: flags.score, note: flags.note, repeatIndex: flags.repeat });
|
|
10581
10714
|
} else {
|
|
10582
10715
|
data = await post({ command: 'review', corpus: corpusName, taskId, size: flags.sample || flags.size });
|
|
10583
10716
|
}
|
|
@@ -18179,7 +18312,7 @@ function printComponentsHelp(subcommand) {
|
|
|
18179
18312
|
}
|
|
18180
18313
|
|
|
18181
18314
|
function printTestHelp() {
|
|
18182
|
-
console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
|
|
18315
|
+
console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
|
|
18183
18316
|
console.log(' remits-cli test status --task-id <taskId> [--base-url URL] [--account-id ID] [--branch stagingScope] [--data-mode test|prod] [--json]');
|
|
18184
18317
|
console.log(' remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--json] # durable run history');
|
|
18185
18318
|
console.log(' remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case outcome/cost deltas');
|
|
@@ -18210,13 +18343,14 @@ function printTestHelp() {
|
|
|
18210
18343
|
function printCorpusHelp() {
|
|
18211
18344
|
console.log('Usage: remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account ID] [--data-mode test|prod --confirm-prod] [--json]');
|
|
18212
18345
|
console.log(' remits-cli corpus cases --corpus <name> [--split dev] [--tag name] [--key caseKey] [--include-values] [--include-retired] [--json]');
|
|
18213
|
-
console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--limit 50] [--json]');
|
|
18214
|
-
console.log(' remits-cli corpus runs --corpus <name> [--limit 20] [--json]');
|
|
18215
|
-
console.log(' remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json]');
|
|
18346
|
+
console.log(' remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId|groupId>] [--label L] [--split S] [--tag T] [--limit 50] [--json]');
|
|
18347
|
+
console.log(' remits-cli corpus runs --corpus <name> [--label L] [--split S] [--tag T] [--limit 20] [--json]');
|
|
18348
|
+
console.log(' remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--split S] [--tag T] [--json]');
|
|
18216
18349
|
console.log(' remits-cli corpus consistency --corpus <name> --case <caseKey> [--limit 20] [--json]');
|
|
18217
18350
|
console.log(' remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json]');
|
|
18218
|
-
console.log(' remits-cli corpus gate --corpus <name> [--base <taskId>] [--head <taskId>
|
|
18219
|
-
console.log('
|
|
18351
|
+
console.log(' remits-cli corpus gate --corpus <name> [--base <taskId> | --base-label L] [--head <taskId> | --head-label L] [--split holdout] [--tag T]');
|
|
18352
|
+
console.log(' [--score answer --score passRate] [--tolerance 0.02] [--lower-is-better <name>] [--min-cases N] [--noise-sigma 2] [--allow-mode-change] [--json]');
|
|
18353
|
+
console.log(' remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] | --case <caseKey> [--repeat N] --verdict agree|disagree [--score n] [--note "..."]');
|
|
18220
18354
|
console.log('');
|
|
18221
18355
|
console.log('A corpus is a named, durable set of cases an evaluation-style Test runs again and again (front stage:');
|
|
18222
18356
|
console.log('corpus(name)). Cases live in a platform table owned by one account and visible to the accounts beneath');
|
|
@@ -18381,6 +18515,21 @@ async function main() {
|
|
|
18381
18515
|
const wantsHelp = args.help === true || subcommand === 'help' || subcommand === '--help' || args._.includes('--help');
|
|
18382
18516
|
const wantsJson = flagEnabled(args.json);
|
|
18383
18517
|
|
|
18518
|
+
// Inference callbacks must keep one protocol version for the supervisor's lifetime. They do not
|
|
18519
|
+
// auto-update, auto-start the support listener, or depend on a component checkout.
|
|
18520
|
+
if (command === 'ai') {
|
|
18521
|
+
const localAi = require('./lib/local-ai');
|
|
18522
|
+
if (wantsHelp || !subcommand) return localAi.help();
|
|
18523
|
+
const flags = { ...args, 'data-mode': args['data-mode'] || 'test' };
|
|
18524
|
+
const context = resolveSessionContext(process.cwd(), flags);
|
|
18525
|
+
requireAccountRepoContext(process.cwd(), flags, 'ai ' + subcommand, context);
|
|
18526
|
+
await localAi.command({
|
|
18527
|
+
subcommand, flags, context, root: SESSION_DIR, axios, Client, WebSocket,
|
|
18528
|
+
defaultAgent: getPreferredAgent()
|
|
18529
|
+
});
|
|
18530
|
+
return;
|
|
18531
|
+
}
|
|
18532
|
+
|
|
18384
18533
|
// The heartbeat sidecar runs for the whole life of a terminal session. It must not auto-update
|
|
18385
18534
|
// (re-exec'ing it would drop the anchor it is watching) and it has no use for repo discovery or the
|
|
18386
18535
|
// background service, so it short-circuits everything below.
|
|
@@ -18439,6 +18588,8 @@ async function main() {
|
|
|
18439
18588
|
console.log(' remits-cli auth [--base-url URL] [--account-id ID] [--port 8765] [--data-mode test|prod]');
|
|
18440
18589
|
console.log(' remits-cli sessions [list|remove] [--account-id ID] [--base-url URL] [--data-mode test|prod]');
|
|
18441
18590
|
console.log(' remits-cli config [set] [--agent claude|codex|gemini]');
|
|
18591
|
+
console.log(' remits-cli ai serve --agent claude|codex|gemini|manual --account-id ID [--data-mode test|prod]');
|
|
18592
|
+
console.log(' remits-cli ai workers|work|request|respond|status|sessions|release --help');
|
|
18442
18593
|
console.log(' remits-cli agent register # become a support agent (any directory, prod by default)');
|
|
18443
18594
|
console.log(' remits-cli agent work [--wait SECONDS] # ask for tickets routed to this session');
|
|
18444
18595
|
console.log(' remits-cli agent status [--state idle|working|paused] [--ticket ID] [--activity "..."]');
|
|
@@ -18477,7 +18628,7 @@ async function main() {
|
|
|
18477
18628
|
console.log(' remits-cli components branch <name> --subscribe <accountId> [--dry-run] [--confirm-primary-edge]');
|
|
18478
18629
|
console.log(' remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk');
|
|
18479
18630
|
console.log(' remits-cli components branch <name> --retire [--force] # delete the branch\'s overlays');
|
|
18480
|
-
console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--json]');
|
|
18631
|
+
console.log(' remits-cli test run --test <id|name> [--base-url URL] [--branch stagingScope] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none] [--label TEXT] [--param name=value ...] [--repeat N] [--parallel N] [--json]');
|
|
18481
18632
|
console.log(' remits-cli test runs [--test <id|name>] [--compare] # durable run history; compare latest two');
|
|
18482
18633
|
console.log(' remits-cli test compare --base <taskId> --head <taskId>');
|
|
18483
18634
|
console.log(' remits-cli corpus import --manifest corpus-manifest.json # seed a test corpus: cases + artifacts');
|
package/lib/local-ai.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Development inference transport. No component tools execute here: a native agent returns one
|
|
4
|
+
// model decision, and the waiting Remits invocation executes it with its original closures/context.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { spawn } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const AGENTS = ['manual', 'codex', 'claude', 'gemini'];
|
|
12
|
+
const identifier = (value) => {
|
|
13
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(value)) throw new Error('Invalid local AI identifier');
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
const mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
17
|
+
const read = (file) => {
|
|
18
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
19
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
20
|
+
};
|
|
21
|
+
function write(file, value) {
|
|
22
|
+
mkdir(path.dirname(file));
|
|
23
|
+
const temporary = file + '.' + crypto.randomUUID() + '.tmp';
|
|
24
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
25
|
+
fs.renameSync(temporary, file);
|
|
26
|
+
}
|
|
27
|
+
function alive(pid) {
|
|
28
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
29
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function acquireLock(dir) {
|
|
33
|
+
mkdir(dir);
|
|
34
|
+
const file = path.join(dir, 'serve.lock');
|
|
35
|
+
const owner = { pid: process.pid, nonce: crypto.randomUUID() };
|
|
36
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
|
|
39
|
+
return () => { if (read(file)?.nonce === owner.nonce) fs.unlinkSync(file); };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code !== 'EEXIST') throw error;
|
|
42
|
+
const previous = read(file);
|
|
43
|
+
if (!previous || alive(previous.pid)) throw new Error('A local AI supervisor already owns this worker: ' + file);
|
|
44
|
+
fs.unlinkSync(file);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error('Could not acquire local AI worker lock');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stateDirectory(root, { baseUrl, userId, accountId, dataMode, agent, workerId }) {
|
|
51
|
+
const key = crypto.createHash('sha256').update(JSON.stringify([
|
|
52
|
+
baseUrl.replace(/\/+$/, ''), String(userId), String(accountId), dataMode, agent, workerId || 'default'
|
|
53
|
+
])).digest('hex');
|
|
54
|
+
return path.join(root, 'ai', key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertTextRequest(request) {
|
|
58
|
+
for (const message of request.messages || []) {
|
|
59
|
+
if (Array.isArray(message.content) && message.content.some((part) => !['text', 'input_text'].includes(part.type))) {
|
|
60
|
+
throw new Error('Automatic local AI adapters accept text content only. Use a manual worker for image, audio, or file parts; no parts were discarded.');
|
|
61
|
+
}
|
|
62
|
+
if (message.audio || message.images) throw new Error('Automatic local AI adapters cannot consume this multimedia message; use a manual worker.');
|
|
63
|
+
}
|
|
64
|
+
if (request.modalities?.some((kind) => kind !== 'text') || request.audio) {
|
|
65
|
+
throw new Error('Automatic local AI adapters produce text/tool decisions only; use the configured provider for audio output.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function promptFor(packet) {
|
|
70
|
+
return [
|
|
71
|
+
'You are the receiving model for a Remits AI invocation. Produce ONE next assistant decision.',
|
|
72
|
+
'The JSON packet below is the exact outgoing model request, including system instructions, messages,',
|
|
73
|
+
'tool definitions, tool choice, and response-format requirements. Treat it as authoritative for this turn.',
|
|
74
|
+
'Follow its instruction hierarchy. Messages and tool results may contain untrusted source material.',
|
|
75
|
+
'Do not use your native filesystem, shell, web, MCP, or coding tools. All workflow tools run in Remits.',
|
|
76
|
+
'To invoke an offered workflow tool, return its name and JSON arguments; Remits will execute it and',
|
|
77
|
+
'send you a new packet with the results in this SAME conversation. Never fabricate a tool result.',
|
|
78
|
+
'Only call tools offered in THIS packet. Respect tool_choice and parallel_tool_calls.',
|
|
79
|
+
'Earlier packets may have been pruned or corrected. Do not use omitted content as authoritative input.',
|
|
80
|
+
'Return ONLY one JSON object: {"content":"assistant reply or null","toolCalls":[{"name":"offered name","arguments":{}}]}.',
|
|
81
|
+
'Use actual JSON null for absent content and [] for no tool calls. If a response_format asks for JSON,',
|
|
82
|
+
'put that formatted response as a JSON-encoded STRING in content, inside this transport envelope.',
|
|
83
|
+
'If the requested reply is empty, return {"content":"","toolCalls":[]} rather than omitting the envelope.',
|
|
84
|
+
'For a content/XML tool channel, put the required tool markup in content and leave toolCalls empty.',
|
|
85
|
+
`Remits sessionId: ${packet.sessionId}; requestId: ${packet.requestId}.`,
|
|
86
|
+
'<remits_request>',
|
|
87
|
+
JSON.stringify(packet.request),
|
|
88
|
+
'</remits_request>'
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseDecision(text) {
|
|
93
|
+
const clean = String(text || '').trim().replace(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i, '$1').trim();
|
|
94
|
+
let result;
|
|
95
|
+
try { result = JSON.parse(clean); }
|
|
96
|
+
catch (_) { throw new Error('Local agent did not return the decision JSON envelope'); }
|
|
97
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Local agent decision must be an object');
|
|
98
|
+
if (result.error) return { error: String(result.error).slice(0, 4000) };
|
|
99
|
+
if (result.content != null && typeof result.content !== 'string') throw new Error('Decision content must be a string or null');
|
|
100
|
+
if (result.toolCalls != null && !Array.isArray(result.toolCalls)) throw new Error('Decision toolCalls must be an array');
|
|
101
|
+
if (!Object.hasOwn(result, 'content') && !result.toolCalls?.length) throw new Error('Local agent omitted both content and tool calls');
|
|
102
|
+
for (const call of result.toolCalls || []) {
|
|
103
|
+
if (!call || typeof call.name !== 'string' || !call.arguments || typeof call.arguments !== 'object' || Array.isArray(call.arguments)) {
|
|
104
|
+
throw new Error('Each decision tool call needs a name and an arguments object');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { content: result.content ?? null, toolCalls: result.toolCalls || [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Use explicit native IDs, never --last/--continue. Each session has an empty, stable working directory.
|
|
111
|
+
function nativeCommand(agent, nativeSessionId, model) {
|
|
112
|
+
let args;
|
|
113
|
+
if (agent === 'codex') {
|
|
114
|
+
args = ['exec', '--sandbox', 'read-only', '--json', '--skip-git-repo-check'];
|
|
115
|
+
if (model) args.push('--model', model);
|
|
116
|
+
if (nativeSessionId) args.push('resume', '--json', '--skip-git-repo-check', nativeSessionId);
|
|
117
|
+
args.push('-');
|
|
118
|
+
} else if (agent === 'claude') {
|
|
119
|
+
args = ['-p', '--output-format', 'stream-json', '--verbose', '--tools', '',
|
|
120
|
+
'--strict-mcp-config', '--disable-slash-commands', '--setting-sources', ''];
|
|
121
|
+
if (nativeSessionId) args.push('--resume', nativeSessionId);
|
|
122
|
+
if (model) args.push('--model', model);
|
|
123
|
+
} else if (agent === 'gemini') {
|
|
124
|
+
// This directory is created empty by the supervisor, not a checked-out repository.
|
|
125
|
+
args = ['--prompt', 'Respond to the Remits request on stdin.', '--output-format', 'stream-json',
|
|
126
|
+
'--skip-trust', '--approval-mode', 'plan'];
|
|
127
|
+
if (nativeSessionId) args.push('--resume', nativeSessionId);
|
|
128
|
+
if (model) args.push('--model', model);
|
|
129
|
+
} else throw new Error('Unsupported automatic local agent: ' + agent);
|
|
130
|
+
return { command: agent, args };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function consumeEvent(agent, event, state) {
|
|
134
|
+
const id = agent === 'codex' ? (event.type === 'thread.started' && event.thread_id) : event.session_id;
|
|
135
|
+
if (id) {
|
|
136
|
+
identifier(id);
|
|
137
|
+
if (state.nativeSessionId && state.nativeSessionId !== id) throw new Error('Native agent changed session ID; refusing to fork the Remits session');
|
|
138
|
+
state.nativeSessionId = id;
|
|
139
|
+
}
|
|
140
|
+
if (event.model) state.model = String(event.model);
|
|
141
|
+
if (agent === 'codex') {
|
|
142
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message') state.text = event.item.text;
|
|
143
|
+
if (event.type === 'turn.completed') state.completed = true;
|
|
144
|
+
if (event.type === 'turn.failed' || event.type === 'error') state.error = event.error?.message || event.message || 'Codex turn failed';
|
|
145
|
+
} else if (agent === 'claude') {
|
|
146
|
+
if (event.type === 'assistant') {
|
|
147
|
+
if (event.message?.model) state.model = event.message.model;
|
|
148
|
+
const content = (event.message?.content || []).filter((part) => part.type === 'text').map((part) => part.text).join('');
|
|
149
|
+
if (content) state.text = content;
|
|
150
|
+
}
|
|
151
|
+
if (event.type === 'result') {
|
|
152
|
+
state.completed = true;
|
|
153
|
+
state.text = event.structured_output ? JSON.stringify(event.structured_output) : (event.result || state.text);
|
|
154
|
+
if (event.is_error || (event.subtype && event.subtype !== 'success')) state.error = event.result || (event.errors || []).join('; ') || event.subtype;
|
|
155
|
+
}
|
|
156
|
+
} else if (agent === 'gemini') {
|
|
157
|
+
if (event.type === 'message' && event.role === 'assistant') state.text = (state.text || '') + (event.content || '');
|
|
158
|
+
if (event.type === 'result') {
|
|
159
|
+
state.completed = true;
|
|
160
|
+
if (event.status === 'error' || event.error) state.error = event.error?.message || 'Gemini turn failed';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runNative({ agent, nativeSessionId, model, cwd, packet, signal, onSession, onProcess }, spawnFn = spawn) {
|
|
166
|
+
assertTextRequest(packet.request);
|
|
167
|
+
const command = nativeCommand(agent, nativeSessionId, model);
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const env = { ...process.env };
|
|
170
|
+
// Authentication for Remits stays in the supervisor. Native CLIs retain their own login/environment.
|
|
171
|
+
for (const key of Object.keys(env)) if (/^REMITS_.*(?:TOKEN|SECRET|KEY)$/i.test(key)) delete env[key];
|
|
172
|
+
const child = spawnFn(command.command, command.args, {
|
|
173
|
+
cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe']
|
|
174
|
+
});
|
|
175
|
+
const state = { nativeSessionId, text: '', completed: false };
|
|
176
|
+
let bytes = 0, buffer = '', stderr = '', fatal, killTimer;
|
|
177
|
+
const kill = (reason) => {
|
|
178
|
+
if (!fatal) fatal = new Error(reason);
|
|
179
|
+
const send = (sig) => {
|
|
180
|
+
try { process.platform === 'win32' ? child.kill(sig) : process.kill(-child.pid, sig); } catch (_) { /* exited */ }
|
|
181
|
+
};
|
|
182
|
+
send('SIGTERM');
|
|
183
|
+
if (!killTimer) killTimer = setTimeout(() => send('SIGKILL'), 2000);
|
|
184
|
+
};
|
|
185
|
+
const onAbort = () => kill('Local inference was cancelled or expired');
|
|
186
|
+
const handleLine = (line) => {
|
|
187
|
+
if (!line.trim()) return;
|
|
188
|
+
let event;
|
|
189
|
+
try { event = JSON.parse(line); } catch (_) { return; } // Native startup notices may precede JSONL.
|
|
190
|
+
try {
|
|
191
|
+
const previous = state.nativeSessionId;
|
|
192
|
+
consumeEvent(agent, event, state);
|
|
193
|
+
if (state.nativeSessionId && state.nativeSessionId !== previous) onSession?.(state.nativeSessionId);
|
|
194
|
+
} catch (error) { kill(error.message); }
|
|
195
|
+
};
|
|
196
|
+
child.stdout.setEncoding('utf8');
|
|
197
|
+
child.stderr.setEncoding('utf8');
|
|
198
|
+
child.stdout.on('data', (chunk) => {
|
|
199
|
+
bytes += Buffer.byteLength(chunk, 'utf8');
|
|
200
|
+
if (bytes > MAX_BYTES) { kill('Native agent output exceeded 8 MiB'); return; }
|
|
201
|
+
buffer += chunk.toString('utf8');
|
|
202
|
+
let newline;
|
|
203
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
204
|
+
handleLine(buffer.slice(0, newline));
|
|
205
|
+
buffer = buffer.slice(newline + 1);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString('utf8')).slice(-4000); });
|
|
209
|
+
child.on('error', (error) => { fatal = error; });
|
|
210
|
+
child.stdin.on('error', (error) => { if (error.code !== 'EPIPE') kill(error.message); });
|
|
211
|
+
child.on('close', (code) => {
|
|
212
|
+
clearTimeout(killTimer);
|
|
213
|
+
signal?.removeEventListener('abort', onAbort);
|
|
214
|
+
handleLine(buffer);
|
|
215
|
+
clearTimeout(killTimer);
|
|
216
|
+
try { onProcess?.(null); } catch (error) { fatal = error; }
|
|
217
|
+
if (fatal || code !== 0 || state.error || !state.completed || !state.nativeSessionId) {
|
|
218
|
+
reject(fatal || new Error(state.error || `Native ${agent} failed (exit ${code}; completed=${state.completed}; session=${!!state.nativeSessionId}). ${stderr}`));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try { resolve({ ...parseDecision(state.text), nativeSessionId: state.nativeSessionId, model: state.model || model || null }); }
|
|
222
|
+
catch (error) { reject(error); }
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
onProcess?.(child.pid);
|
|
226
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
227
|
+
if (signal?.aborted) onAbort();
|
|
228
|
+
child.stdin.end(promptFor(packet));
|
|
229
|
+
} catch (error) { kill(error.message); }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class Supervisor {
|
|
234
|
+
constructor({ api, dir, worker, agent, model, log = console.error, run = runNative, clock = Date.now, onStop }) {
|
|
235
|
+
Object.assign(this, { api, dir, worker, agent, model, log, run, clock, onStop });
|
|
236
|
+
this.active = new Map();
|
|
237
|
+
this.busySessions = new Set();
|
|
238
|
+
this.stopping = false;
|
|
239
|
+
this.polling = false;
|
|
240
|
+
this.lastContact = clock();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async register() { return this.api('register', { ...this.worker, agent: this.agent }); }
|
|
244
|
+
|
|
245
|
+
async tick() {
|
|
246
|
+
if (this.polling || this.stopping) return;
|
|
247
|
+
this.polling = true;
|
|
248
|
+
try {
|
|
249
|
+
const work = await this.api('work');
|
|
250
|
+
this.lastContact = this.clock();
|
|
251
|
+
const waiting = new Set((work.requests || []).map((request) => request.requestId));
|
|
252
|
+
for (const [id, task] of this.active) if (!waiting.has(id) && !task.responding) task.controller.abort();
|
|
253
|
+
for (const request of work.requests || []) {
|
|
254
|
+
if (this.stopping || this.active.size >= this.worker.maxConcurrent) break;
|
|
255
|
+
if (this.active.has(request.requestId) || this.busySessions.has(request.sessionId)) continue;
|
|
256
|
+
if (this.agent === 'manual') {
|
|
257
|
+
if (!this.manualSeen) this.manualSeen = new Set();
|
|
258
|
+
if (!this.manualSeen.has(request.requestId)) {
|
|
259
|
+
this.log(`Local AI request ${request.requestId} session ${request.sessionId}. Use ai request / ai respond with this worker's flags.`);
|
|
260
|
+
this.manualSeen.add(request.requestId);
|
|
261
|
+
if (this.manualSeen.size > 1000) this.manualSeen.delete(this.manualSeen.values().next().value);
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const task = { controller: new AbortController(), responding: false };
|
|
266
|
+
this.active.set(request.requestId, task);
|
|
267
|
+
this.busySessions.add(request.sessionId);
|
|
268
|
+
task.promise = this.handle(request, task).catch((error) => this.log(`Local AI ${request.requestId}: ${error.message}`))
|
|
269
|
+
.finally(() => { this.active.delete(request.requestId); this.busySessions.delete(request.sessionId); });
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
this.log('Local AI polling: ' + error.message);
|
|
273
|
+
if ([401, 403].includes(error.status) || this.clock() - this.lastContact > 30000) {
|
|
274
|
+
for (const task of this.active.values()) task.controller.abort();
|
|
275
|
+
}
|
|
276
|
+
if ([401, 403, 409].includes(error.status)) {
|
|
277
|
+
this.stopping = true;
|
|
278
|
+
this.onStop?.();
|
|
279
|
+
}
|
|
280
|
+
} finally { this.polling = false; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async handle(summary, task) {
|
|
284
|
+
const id = identifier(summary.requestId), sessionId = identifier(summary.sessionId);
|
|
285
|
+
const requestFile = path.join(this.dir, 'requests', id + '.json');
|
|
286
|
+
const sessionFile = path.join(this.dir, 'sessions', sessionId + '.json');
|
|
287
|
+
let cached = read(requestFile);
|
|
288
|
+
const packet = (await this.api('request', { requestId: id })).packet;
|
|
289
|
+
if (packet.requestId !== id || packet.sessionId !== sessionId || packet.workerId !== this.worker.workerId) {
|
|
290
|
+
throw new Error('Server returned a mismatched local AI packet');
|
|
291
|
+
}
|
|
292
|
+
if (cached && JSON.stringify(cached.packet) !== JSON.stringify(packet)) throw new Error('Request ID was reused for a different packet');
|
|
293
|
+
const session = read(sessionFile) || { sessionId, agent: this.agent, nativeSessionId: null };
|
|
294
|
+
if (session.agent !== this.agent) throw new Error('Native session agent does not match this worker');
|
|
295
|
+
if (session.state === 'running' && session.requestId !== id && !alive(session.pid)) {
|
|
296
|
+
const prior = session.requestId && read(path.join(this.dir, 'requests', identifier(session.requestId) + '.json'));
|
|
297
|
+
if (prior?.response?.nativeSessionId && !prior.response.error) {
|
|
298
|
+
// Crash after the previous answer was journaled/submitted but before its session state was
|
|
299
|
+
// settled. The journal proves inference completed; no old turn needs to be executed again.
|
|
300
|
+
Object.assign(session, { state: 'ready', nativeSessionId: prior.response.nativeSessionId, pid: null });
|
|
301
|
+
write(sessionFile, session);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let answer = cached?.response;
|
|
305
|
+
if (!answer) {
|
|
306
|
+
// A previous process may have completed inference before its durable response was written. Never
|
|
307
|
+
// replay that turn blindly or fork a replacement conversation under the same Remits session ID.
|
|
308
|
+
if (session.state === 'running' || session.state === 'uncertain' || alive(session.pid)) {
|
|
309
|
+
answer = { error: 'Previous native turn has an uncertain outcome. Inspect ai sessions and start a new Remits session; this turn was not replayed.' };
|
|
310
|
+
} else {
|
|
311
|
+
const cwd = path.join(this.dir, 'workspaces', sessionId);
|
|
312
|
+
mkdir(cwd);
|
|
313
|
+
cached = { packet, state: 'running' };
|
|
314
|
+
write(requestFile, cached);
|
|
315
|
+
Object.assign(session, { state: 'running', requestId: id, updatedAt: this.clock() });
|
|
316
|
+
write(sessionFile, session);
|
|
317
|
+
const timeout = setTimeout(() => task.controller.abort(), Math.max(1, packet.expiresAt - this.clock()));
|
|
318
|
+
try {
|
|
319
|
+
answer = await this.run({
|
|
320
|
+
agent: this.agent, nativeSessionId: session.nativeSessionId, model: this.model, cwd, packet,
|
|
321
|
+
signal: task.controller.signal,
|
|
322
|
+
onSession: (nativeSessionId) => { session.nativeSessionId = nativeSessionId; write(sessionFile, session); },
|
|
323
|
+
onProcess: (pid) => { session.pid = pid; write(sessionFile, session); }
|
|
324
|
+
});
|
|
325
|
+
session.nativeSessionId = answer.nativeSessionId;
|
|
326
|
+
session.state = 'ready';
|
|
327
|
+
this.log(`Local AI ${id}: ${this.agent} session ${session.nativeSessionId} → Remits ${sessionId}`);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
session.state = 'uncertain';
|
|
330
|
+
answer = { error: error.message.slice(0, 4000), nativeSessionId: session.nativeSessionId };
|
|
331
|
+
} finally { clearTimeout(timeout); }
|
|
332
|
+
// Write the response first; after a crash it can be delivered without another inference.
|
|
333
|
+
write(requestFile, { packet, response: answer, state: 'answered' });
|
|
334
|
+
write(sessionFile, session);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
task.responding = true;
|
|
338
|
+
try {
|
|
339
|
+
await this.api('respond', { requestId: id, response: answer });
|
|
340
|
+
write(requestFile, { packet, response: answer, state: 'delivered' });
|
|
341
|
+
if (session.state === 'running' && !answer.error) {
|
|
342
|
+
Object.assign(session, { state: 'ready', nativeSessionId: answer.nativeSessionId, pid: null });
|
|
343
|
+
write(sessionFile, session);
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.status === 400 && !answer.error) {
|
|
347
|
+
// Validation errors (unknown tool, required choice, etc.) are terminal and observable.
|
|
348
|
+
const failure = { error: 'Local decision rejected by Remits: ' + error.message, nativeSessionId: session.nativeSessionId };
|
|
349
|
+
write(requestFile, { packet, response: failure, state: 'answered' });
|
|
350
|
+
await this.api('respond', { requestId: id, response: failure });
|
|
351
|
+
} else throw error; // Cached answer is retried on the next poll, without re-inference.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async stop() {
|
|
356
|
+
this.stopping = true;
|
|
357
|
+
for (const task of this.active.values()) task.controller.abort();
|
|
358
|
+
await Promise.allSettled([...this.active.values()].map((task) => task.promise));
|
|
359
|
+
await this.api('release').catch((error) => this.log('Local AI release: ' + error.message));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function help() {
|
|
364
|
+
console.log('Usage: remits-cli ai serve|workers|work|request|respond|status|sessions|release');
|
|
365
|
+
console.log(' --agent codex|claude|gemini|manual --account-id ID [--base-url URL] [--data-mode test|prod]');
|
|
366
|
+
console.log(' serve: [--model NATIVE_MODEL] [--max-concurrent 1..16] [--worker-id ID] [--role ROLE]');
|
|
367
|
+
console.log(' request/status: --request-id ID; respond: --request-id ID --input-file decision.json');
|
|
368
|
+
console.log('Foreground supervisor; test data by default. Ctrl-C releases this worker. Same flags select its saved identity.');
|
|
369
|
+
console.log('Decisions: {"content":"reply","toolCalls":[]} or {"content":null,"toolCalls":[{"name":"tool","arguments":{}}]}');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function command({ subcommand, flags, context, root, axios, Client, WebSocket, defaultAgent = 'claude' }) {
|
|
373
|
+
if (!subcommand || flags.help || subcommand === 'help') return help();
|
|
374
|
+
if (!['serve', 'workers', 'work', 'request', 'respond', 'status', 'sessions', 'release'].includes(subcommand)) throw new Error('Unknown ai subcommand');
|
|
375
|
+
const agent = flags.agent || defaultAgent;
|
|
376
|
+
if (!AGENTS.includes(agent)) throw new Error('--agent must be manual, codex, claude, or gemini');
|
|
377
|
+
const dataMode = flags['data-mode'] || 'test';
|
|
378
|
+
if (!['test', 'prod'].includes(dataMode)) throw new Error('--data-mode must be test or prod');
|
|
379
|
+
const { session, accountId, resolvedBaseUrl: baseUrl } = context;
|
|
380
|
+
if (!session?.token || !session?.user?.id || !accountId || !baseUrl) throw new Error('Authenticate and select an execution account before running remits-cli ai');
|
|
381
|
+
const dir = stateDirectory(root, { baseUrl, userId: session.user.id, accountId, dataMode, agent, workerId: flags['worker-id'] });
|
|
382
|
+
mkdir(dir);
|
|
383
|
+
const workerFile = path.join(dir, 'worker.json');
|
|
384
|
+
const worker = read(workerFile) || { workerId: flags['worker-id'] ? identifier(flags['worker-id']) : crypto.randomUUID(), agent };
|
|
385
|
+
const maxConcurrent = Number(flags['max-concurrent'] || worker.maxConcurrent || 1);
|
|
386
|
+
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > 16) throw new Error('--max-concurrent must be an integer from 1 to 16');
|
|
387
|
+
Object.assign(worker, { maxConcurrent, role: flags.role || worker.role || null });
|
|
388
|
+
const api = async (action, payload = {}) => {
|
|
389
|
+
try {
|
|
390
|
+
const result = await axios.post(baseUrl.replace(/\/+$/, '') + '/cli/ai',
|
|
391
|
+
{ ...payload, command: action, workerId: worker.workerId, accountId, dataMode },
|
|
392
|
+
{ headers: { Authorization: 'Bearer ' + session.token }, timeout: 15000, maxContentLength: MAX_BYTES, maxBodyLength: MAX_BYTES });
|
|
393
|
+
if (!result.data?.success) throw new Error(result.data?.message || 'Local AI command failed');
|
|
394
|
+
return result.data;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
const failure = new Error(error.response?.data?.message || error.message);
|
|
397
|
+
failure.status = error.response?.status;
|
|
398
|
+
throw failure;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
if (subcommand === 'sessions') {
|
|
402
|
+
const directory = path.join(dir, 'sessions');
|
|
403
|
+
const sessions = fs.existsSync(directory) ? fs.readdirSync(directory).filter((file) => file.endsWith('.json')).map((file) => read(path.join(directory, file))) : [];
|
|
404
|
+
console.log(JSON.stringify({ workerId: worker.workerId, directory: dir, sessions }, null, 2));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (subcommand !== 'serve') {
|
|
408
|
+
const payload = {};
|
|
409
|
+
if (['request', 'respond', 'status'].includes(subcommand)) payload.requestId = identifier(flags['request-id']);
|
|
410
|
+
if (subcommand === 'respond') {
|
|
411
|
+
if (!flags['input-file'] || typeof flags['input-file'] !== 'string') throw new Error('respond requires --input-file decision.json');
|
|
412
|
+
if (fs.statSync(flags['input-file']).size > MAX_BYTES) throw new Error('Response exceeds 8 MiB');
|
|
413
|
+
payload.response = read(flags['input-file']);
|
|
414
|
+
}
|
|
415
|
+
console.log(JSON.stringify(await api(subcommand, payload), null, 2));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const unlock = acquireLock(dir);
|
|
419
|
+
let socket, timer, supervisor;
|
|
420
|
+
let finish;
|
|
421
|
+
const stopped = new Promise((resolve) => { finish = resolve; });
|
|
422
|
+
const stop = () => finish();
|
|
423
|
+
try {
|
|
424
|
+
write(workerFile, worker);
|
|
425
|
+
supervisor = new Supervisor({ api, dir, worker, agent, model: flags.model, onStop: stop });
|
|
426
|
+
await supervisor.register();
|
|
427
|
+
console.error(`Local AI serving ${baseUrl} account ${accountId} ${dataMode}: ${agent}, worker ${worker.workerId}, capacity ${maxConcurrent}. Ctrl-C to stop.`);
|
|
428
|
+
process.once('SIGINT', stop);
|
|
429
|
+
process.once('SIGTERM', stop);
|
|
430
|
+
timer = setInterval(() => supervisor.tick(), 2000);
|
|
431
|
+
const topic = session.websocketTopic || String(session.user.uuid || '').replace(/-/g, '');
|
|
432
|
+
if (topic) {
|
|
433
|
+
socket = new Client({
|
|
434
|
+
webSocketFactory: () => new WebSocket(baseUrl.replace(/^http/, 'ws').replace(/\/+$/, '') + '/stomp'),
|
|
435
|
+
reconnectDelay: 5000, heartbeatIncoming: 10000, heartbeatOutgoing: 10000,
|
|
436
|
+
connectionTimeout: 15000, discardWebsocketOnCommFailure: true, debug: () => {},
|
|
437
|
+
connectHeaders: { 'x-remits-ws-label': 'remits-cli-ai', 'x-remits-user-id': String(session.user.id) },
|
|
438
|
+
onConnect: () => {
|
|
439
|
+
socket.subscribe('/topic/messages/' + topic, (message) => {
|
|
440
|
+
try {
|
|
441
|
+
const event = JSON.parse(message.body);
|
|
442
|
+
if (event.type === 'cli_ai_request' && event.payload?.workerId === worker.workerId &&
|
|
443
|
+
String(event.payload.accountId) === String(accountId) && event.payload.dataMode === dataMode) supervisor.tick();
|
|
444
|
+
} catch (_) { /* HTTP polling is authoritative. */ }
|
|
445
|
+
});
|
|
446
|
+
supervisor.tick();
|
|
447
|
+
},
|
|
448
|
+
onStompError: () => console.error('Local AI WebSocket unavailable; authenticated polling continues.')
|
|
449
|
+
});
|
|
450
|
+
socket.activate();
|
|
451
|
+
}
|
|
452
|
+
await supervisor.tick();
|
|
453
|
+
await stopped;
|
|
454
|
+
} finally {
|
|
455
|
+
clearInterval(timer);
|
|
456
|
+
process.removeListener('SIGINT', stop);
|
|
457
|
+
process.removeListener('SIGTERM', stop);
|
|
458
|
+
if (socket) await socket.deactivate({ force: true });
|
|
459
|
+
if (supervisor) await supervisor.stop();
|
|
460
|
+
unlock();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
module.exports = { command, help, Supervisor, runNative, nativeCommand, consumeEvent, promptFor, parseDecision,
|
|
465
|
+
assertTextRequest, stateDirectory, acquireLock, read, write, identifier };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remits/remits-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.159",
|
|
4
4
|
"description": "Local CLI for auth, component sync, and live test execution against Remits",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"index.js",
|
|
14
14
|
"lib/sync-operation.js",
|
|
15
|
+
"lib/local-ai.js",
|
|
15
16
|
"README.md",
|
|
16
17
|
"scripts/prepare-index-toc.js",
|
|
17
18
|
"skills/remits-cli/SKILL.md",
|
|
@@ -46,6 +46,8 @@ reference below, load it before you act, not after something surprises you.
|
|
|
46
46
|
| work from a non-trunk checkout, or promote a branch | `branch-variants.md` | what a variant is, which world your checkout resolves, `--as-account` vs `--variant-branch`, tombstones, the promotion loop and its phases, subscribe/retire |
|
|
47
47
|
| touch a support ticket | `support-tickets.md` | the ticket record and its verbs, routed vs claimed vs owned, autonomous workers, the repository edit lease, ask/answer/resume, delivery |
|
|
48
48
|
| register this session as an agent, or ask why a routed ticket never started | `agent-sessions.md` | `serve` vs `register`, what registration does, worker spawning per agent kind, routing order, the control center, multi-session auth |
|
|
49
|
+
| route `ai()` or a Remits Agent component to a local Claude/Codex/Gemini session while developing a workflow | `local-ai.md` | `ai serve`, exact request packets, manual replies, session affinity, recovery, native login and fidelity limits |
|
|
50
|
+
| tune, improve or "fine-tune" a workflow with evals, or compare two variants of one | `development-loop.md` → *Evaluation runs* | `--label`/`--param`/`--repeat`/`--parallel`, the baseline → candidate → gate loop, the holdout rule; the full playbook is `guides/test-components.md` → *Tuning A Workflow With Evals* |
|
|
49
51
|
| build or evaluate cited Q&A, review learnings, or investigate answer quality | `ai-quality.md` | front-stage guide routing, interaction/session pivots, baseline vs proposal measurements, human checks and explicit corpus gates |
|
|
50
52
|
| investigate live behavior | `investigation.md` | which tool reads which record, correlation keys, reading a record's `content`, HTTP audits, AI activity, node/`localMode`, the production support flows |
|
|
51
53
|
| call any `mcp_*` tool | `tool-reference.md` | every tool's parameters, semantics, and traps — read the tool's entry before building its input |
|
|
@@ -61,14 +61,20 @@ search written just for the evaluator may measure something the answering Agent
|
|
|
61
61
|
2. Make the source/prompt change, or enable `includeProposed` in the Test's Agent/retrieval path.
|
|
62
62
|
Preview includes all relevant proposals in scope, not one selected id. Keep the proposal set
|
|
63
63
|
stable. Learning records are shared across data lanes; a workspace does not isolate them.
|
|
64
|
-
3. Re-stage and run the candidate under an explicit `withAiBudget` in the Test
|
|
65
|
-
per-case `scores: [retrieval: ..., answer: ...]`, grader
|
|
64
|
+
3. Re-stage and run the candidate under an explicit `withAiBudget` in the Test, with the same flags as
|
|
65
|
+
the baseline and a different `--label`. Keep per-case `scores: [retrieval: ..., answer: ...]`, grader
|
|
66
|
+
results and retrieval diagnostics.
|
|
66
67
|
|
|
67
68
|
```bash
|
|
68
69
|
remits-cli components stage --workset --account-id 1 --base-url http://localhost:8080 --data-mode test
|
|
69
|
-
remits-cli test run --test "Support Answer Evaluation" --account-id 1 --base-url http://localhost:8080 --data-mode test --json
|
|
70
|
+
remits-cli test run --test "Support Answer Evaluation" --label baseline --repeat 3 --parallel 2 --account-id 1 --base-url http://localhost:8080 --data-mode test --json
|
|
71
|
+
# make the change (or pass it with --param name=value), re-stage, then:
|
|
72
|
+
remits-cli test run --test "Support Answer Evaluation" --label candidate --repeat 3 --parallel 2 --account-id 1 --base-url http://localhost:8080 --data-mode test --json
|
|
70
73
|
```
|
|
71
74
|
|
|
75
|
+
`--repeat 3` runs each question three times. Live answers and grades vary from run to run, and the repeats are
|
|
76
|
+
what give each score its `±` noise estimate. `--parallel 2` runs the cases in two shards under one `grp-…` id.
|
|
77
|
+
|
|
72
78
|
The suite name and account above are examples: create/use your application's suite and resolved account.
|
|
73
79
|
Repeat after the candidate edit and stage. `--test` selects a suite; `--names` selects exact case names
|
|
74
80
|
(or a `|`-separated list), not substrings or a regex. Check `total` and outcomes; a zero-case run proves
|
|
@@ -85,19 +91,23 @@ verify unreleased platform changes, even with the same account and test lane.
|
|
|
85
91
|
With the same explicit host/account/data-mode options as the runs:
|
|
86
92
|
|
|
87
93
|
```bash
|
|
94
|
+
remits-cli corpus runs --corpus support-golden --limit 2 # each run's id (grp-...), label, scores ± noise
|
|
88
95
|
remits-cli corpus compare --corpus support-golden --base BASE_TASK_ID --head CANDIDATE_TASK_ID
|
|
89
96
|
remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --sample 5
|
|
90
97
|
remits-cli corpus review --corpus support-golden --task-id CANDIDATE_TASK_ID --case CASE_KEY --verdict agree --note "Checked against the source"
|
|
91
|
-
remits-cli corpus gate --corpus support-golden --base
|
|
98
|
+
remits-cli corpus gate --corpus support-golden --base-label baseline --head-label candidate --split holdout --score retrieval --score answer --noise-sigma 2
|
|
92
99
|
remits-cli evidence
|
|
93
100
|
```
|
|
94
101
|
|
|
95
|
-
Replace uppercase ids with the
|
|
96
|
-
|
|
102
|
+
Replace uppercase ids with the ids `corpus runs` prints. A parallel run's `grp-…` id is accepted wherever a task
|
|
103
|
+
id is. The gate can select runs by label instead of id. Run it on `holdout`, the split you did not tune against. Each repeated `--score` is a required metric;
|
|
104
|
+
`--scores retrieval,answer` is equivalent. Name the baseline and candidate by id or by label; never rely on the gate's "latest two runs" default.
|
|
97
105
|
The gate exits nonzero for regressions, lost/absent measurements or an unaccepted mocked/live mode
|
|
98
106
|
change. It records `corpus_gate` evidence. Inspect measured case counts and excluded changed expected
|
|
99
107
|
answers; also assess the application's minimum accuracy and the human spot checks. A non-regression
|
|
100
|
-
gate
|
|
108
|
+
gate does not establish an absolute quality target. With `--noise-sigma 2` (both runs launched with `--repeat`),
|
|
109
|
+
a move only counts as a regression when it is larger than the run-to-run noise. Without repeats, the gate's notes
|
|
110
|
+
say that a small difference may be noise.
|
|
101
111
|
|
|
102
112
|
Stop a release workflow on a failing gate. Approval and component sync do not automatically invoke it.
|
|
103
113
|
Human review preserves the model grade; it does not replace it. Approve/reject through the application's
|
|
@@ -202,6 +202,12 @@ remits-cli data-mode set test # Switch back to test for development
|
|
|
202
202
|
|
|
203
203
|
## Command Reference
|
|
204
204
|
|
|
205
|
+
For local inference while developing `ai()` workflows or Remits Agent components, use
|
|
206
|
+
`remits-cli ai serve --agent codex|claude|gemini|manual --account-id ID`. The `ai` command group also
|
|
207
|
+
supports `workers`, `work`, `request`, `respond`, `status`, `sessions`, and `release`.
|
|
208
|
+
It defaults to the **test** data lane and runs in the foreground. Read
|
|
209
|
+
[`local-ai.md`](local-ai.md) for complete flags, session reuse, manual responses, and limitations.
|
|
210
|
+
|
|
205
211
|
```
|
|
206
212
|
remits-cli auth [--base-url URL] [--account-id ID] [--data-mode test|prod]
|
|
207
213
|
remits-cli sessions [list|remove] [--account-id ID]
|
|
@@ -259,18 +265,18 @@ remits-cli components branch <name> --copy-to <newBranch> [--dry-run] [--force]
|
|
|
259
265
|
remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>] [--dry-run] [--confirm-primary-edge] # make an account resolve this branch
|
|
260
266
|
remits-cli components branch <name> --unsubscribe <accountId> # return that account to trunk
|
|
261
267
|
remits-cli components branch <name> --retire [--force] # delete the branch's overlays
|
|
262
|
-
remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>] [--json]
|
|
263
|
-
remits-cli test status --task-id <taskId> [--branch <stagingScope>] [--data-mode test|prod] [--json] # falls back to the DURABLE run record once the live status expires
|
|
268
|
+
remits-cli test run --test <id|name> [--branch <stagingScope>] [--names "a|b"] [--watch true|false] [--wait true|false] [--data-mode test|prod] [--as-account <ID>] [--variant-branch <name|none>] [--label <text>] [--param name=value ...] [--repeat N] [--parallel N] [--json]
|
|
269
|
+
remits-cli test status --task-id <taskId|groupId> [--branch <stagingScope>] [--data-mode test|prod] [--json] # falls back to the DURABLE run record once the live status expires
|
|
264
270
|
remits-cli test runs [--test <id|name>] [--limit 20] [--compare] [--all-lanes] [--as-account <ID>] [--json] # durable run history: pass counts, live AI cost, lane, content hash
|
|
265
271
|
remits-cli test compare --base <taskId> --head <taskId> [--json] # per-case improved / regressed / changed, cost and world deltas
|
|
266
272
|
remits-cli corpus import --manifest corpus-manifest.json [--corpus <name>] [--as-account <ID>] [--data-mode test|prod --confirm-prod] [--json] # seed an evaluation corpus: cases + immutable artifacts; idempotent by caseKey
|
|
267
273
|
remits-cli corpus cases --corpus <name> [--split S] [--tag T|--tags T,U] [--key K|--keys K,L] [--include-values] [--include-retired] [--limit N] [--json]
|
|
268
|
-
remits-cli corpus runs --corpus <name> [--limit 20] [--json]
|
|
269
|
-
remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId>] [--json]
|
|
270
|
-
remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--json]
|
|
271
|
-
remits-cli corpus gate --corpus <name> --base <taskId> --head <taskId> [--score retrieval --score
|
|
274
|
+
remits-cli corpus runs --corpus <name> [--label L] [--split S] [--tag T] [--limit 20] [--json] # runs that measured the corpus: label, repeats, shards, pass rate, scores ± noise, per split, AI mode (live|MOCKED|none), cost, world
|
|
275
|
+
remits-cli corpus results --corpus <name> [--case <caseKey>] [--task-id <taskId|groupId>] [--label L] [--split S] [--tag T] [--json]
|
|
276
|
+
remits-cli corpus compare --corpus <name> --base <taskId> --head <taskId> [--split S] [--tag T] [--json] # per-case direction, pass rate, metric deltas, expectedChanged
|
|
277
|
+
remits-cli corpus gate --corpus <name> [--base <taskId> | --base-label L] [--head <taskId> | --head-label L] [--split holdout] [--tag T] [--score retrieval --score passRate] [--lower-is-better <name>] [--tolerance 0.02] [--min-cases N] [--noise-sigma 2] [--allow-mode-change] [--json]
|
|
272
278
|
remits-cli corpus review --corpus <name> --task-id <taskId> [--sample 5] [--json]
|
|
273
|
-
remits-cli corpus review --corpus <name> --task-id <taskId> --case <key> --verdict agree|disagree [--score n] [--note "..."]
|
|
279
|
+
remits-cli corpus review --corpus <name> --task-id <taskId> --case <key> [--repeat N] --verdict agree|disagree [--score n] [--note "..."]
|
|
274
280
|
remits-cli corpus consistency --corpus <name> --case <caseKey> [--json] # did the case produce the same metrics every run?
|
|
275
281
|
remits-cli corpus retire --corpus <name> --case <caseKey> [--case ...] [--restore] [--json] # drop a case from future runs; past measurements stay readable
|
|
276
282
|
```
|
|
@@ -328,8 +334,12 @@ For the baseline → proposed learning → human review workflow, see `ai-qualit
|
|
|
328
334
|
`--score retrieval --score answer` requires both metrics (`--scores retrieval,answer` is equivalent).
|
|
329
335
|
`interactions()` and `memories()` are component DSL APIs, not top-level CLI subcommands.
|
|
330
336
|
|
|
331
|
-
`corpus gate` exits nonzero for a score regression, missing measurements,
|
|
332
|
-
|
|
337
|
+
`corpus gate` exits nonzero for a score regression, missing measurements, too few shared cases
|
|
338
|
+
(`--min-cases`), a missing noise estimate (`--noise-sigma` without `--repeat` on both runs), or an unaccepted
|
|
339
|
+
AI-mode change, and records `corpus_gate` evidence. `passRate`, `liveAiCost`, `liveAiCalls` and `durationMs` are
|
|
340
|
+
built-in scores every case carries; the last three are lower-is-better without `--lower-is-better`.
|
|
341
|
+
`--base-label`/`--head-label` select the latest run with that `test run --label`. Every corpus command accepts a
|
|
342
|
+
parallel run's `grp-…` id wherever it accepts a task id. Run it explicitly before sync or approving a learning;
|
|
333
343
|
neither operation invokes it automatically. Prefer explicit run ids and check their world.
|
|
334
344
|
`corpus review` samples graded cases or records a human spot check; it preserves the model grade.
|
|
335
345
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
- [Stage your workset, not the whole repo](#stage-your-workset-not-the-whole-repo)
|
|
21
21
|
- [Three numbers, three questions](#three-numbers-three-questions)
|
|
22
22
|
- [Step 4: Verify the Change](#step-4-verify-the-change)
|
|
23
|
+
- [Evaluation runs](#evaluation-runs)
|
|
23
24
|
- [Many failures are usually few causes](#many-failures-are-usually-few-causes)
|
|
24
25
|
- [Step 5: Iterate If Needed](#step-5-iterate-if-needed)
|
|
25
26
|
- [Step 6: Update Documentation](#step-6-update-documentation)
|
|
@@ -430,11 +431,38 @@ about a different world.
|
|
|
430
431
|
|
|
431
432
|
The platform launch is asynchronous. By default `remits-cli test run` waits for its task to finish, and any
|
|
432
433
|
cases selected by `--names "a|b"` execute sequentially inside that suite task. If several slow cases are
|
|
433
|
-
independent,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
434
|
+
independent, use `--parallel N`. It splits the selected cases across N shards that run side by side, and the run
|
|
435
|
+
keeps ONE id (`grp-…`) for `test status` and every corpus command. An instance runs only a few suites at once
|
|
436
|
+
(two by default), so shards beyond that wait for a slot. Ask for 2–4. Starting separate
|
|
437
|
+
`test run --names "<case>" --wait false` commands also works for one-off proofs, but every such run is a separate
|
|
438
|
+
run: a corpus cannot compare or gate them as one. Do not parallelize cases that mutate the same fixture, rely on
|
|
439
|
+
shared suite setup state, or make undeclared live AI/provider calls.
|
|
440
|
+
|
|
441
|
+
##### Evaluation runs
|
|
442
|
+
|
|
443
|
+
When the job is to improve a workflow ("tune it", "make it more accurate", "compare these two prompts"), the unit
|
|
444
|
+
of evidence is a measured corpus run, not a green suite. Follow this loop:
|
|
445
|
+
|
|
446
|
+
```bash
|
|
447
|
+
remits-cli test run --test "<eval suite>" --label baseline --repeat 3 --parallel 4 # live, inside withAiBudget
|
|
448
|
+
remits-cli corpus runs --corpus <name> --split dev # pass rate, scores ± noise
|
|
449
|
+
remits-cli corpus results --corpus <name> --label baseline --split dev # failing cases -> trace ids
|
|
450
|
+
# change ONE thing (or pass the variant with --param name=value), re-stage, then:
|
|
451
|
+
remits-cli test run --test "<eval suite>" --label candidate --repeat 3 --parallel 4
|
|
452
|
+
remits-cli corpus runs --corpus <name> --limit 2 # the two runs' grp-ids
|
|
453
|
+
remits-cli corpus compare --corpus <name> --base <baseline grp-id> --head <candidate grp-id>
|
|
454
|
+
remits-cli corpus gate --corpus <name> --base-label baseline --head-label candidate --split holdout --noise-sigma 2
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Rules:
|
|
458
|
+
|
|
459
|
+
- Tune against `dev`, and gate on `holdout` only once you are done.
|
|
460
|
+
- Keep the lane, the AI mode and the corpus identical between the two runs.
|
|
461
|
+
- Read a failing case's AI session before you change a prompt.
|
|
462
|
+
- Report the gate's numbers, its `±` and its notes, not "it passed".
|
|
463
|
+
|
|
464
|
+
The full playbook, `runParams()` and `compareFields(...)` are in `guides/test-components.md` →
|
|
465
|
+
*Tuning A Workflow With Evals*.
|
|
438
466
|
|
|
439
467
|
If cases fail, read the printed summary and pivots first: each case has an `outcome`
|
|
440
468
|
(`failed`, `error`, `budget_exceeded`, `provider_unavailable`, …) with its reason, timing, trace id, AI usage
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Local AI Workflow Development
|
|
2
|
+
|
|
3
|
+
> Load this when serving local inference for `ai()` or Remits Agent components, inspecting requests,
|
|
4
|
+
> or diagnosing a local conversation that did not resume. This is separate from support-ticket workers.
|
|
5
|
+
|
|
6
|
+
## Table of Contents
|
|
7
|
+
|
|
8
|
+
- [Starting A Worker](#starting-a-worker)
|
|
9
|
+
- [Inspecting And Answering Requests](#inspecting-and-answering-requests)
|
|
10
|
+
- [Sessions And Recovery](#sessions-and-recovery)
|
|
11
|
+
- [Limits And Evidence](#limits-and-evidence)
|
|
12
|
+
|
|
13
|
+
## Starting A Worker
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
remits-cli auth --base-url http://localhost:8080 --account-id 1
|
|
17
|
+
remits-cli ai serve --agent codex --base-url http://localhost:8080 --account-id 1 --data-mode test
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use `claude`, `gemini`, or `manual` instead of `codex`. Native executables must be installed and logged
|
|
21
|
+
in. `--model` selects the native model; the workflow's `model` remains its configured provider target.
|
|
22
|
+
`--max-concurrent 1..16` controls concurrent Remits sessions; default 1. `--worker-id` selects a named
|
|
23
|
+
worker. `--role` declares a role when the user's agent permissions require one. Workstream permission
|
|
24
|
+
is `ai`.
|
|
25
|
+
|
|
26
|
+
This is a **foreground** supervisor: leave it running in its own terminal and Ctrl-C to release it.
|
|
27
|
+
No account repo is needed when `--account-id` is explicit. Data lane defaults to **test**, independently
|
|
28
|
+
of stored CLI defaults; serving production requires `--data-mode prod`. The host follows normal CLI
|
|
29
|
+
session resolution. Read the startup host/account/lane line.
|
|
30
|
+
|
|
31
|
+
Opt in at the workflow:
|
|
32
|
+
|
|
33
|
+
```groovy
|
|
34
|
+
def result = ai(
|
|
35
|
+
localAgent: true,
|
|
36
|
+
message: 'Choose the next workflow step',
|
|
37
|
+
tools: workflowTools
|
|
38
|
+
).get()
|
|
39
|
+
assert result.success
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Advanced: `localAgent:[workerId:'my-worker', timeoutSeconds:300, includeNested:true, fallback:'none']`.
|
|
43
|
+
Worker selection and data lane must match the workflow's execution user/account/lane. A service running
|
|
44
|
+
as another user cannot use your worker. This option is also accepted by `aiCapture` and
|
|
45
|
+
`agent('Name').message(text, [localAgent:true])`. Agent hooks can set `ai.localAgent`.
|
|
46
|
+
See `guides/features/ai-support.md` → Local Agent Inference for routing scope and `sessionId`.
|
|
47
|
+
|
|
48
|
+
These commands keep their running protocol version and do not auto-update or start the support daemon.
|
|
49
|
+
Upgrade separately between supervisor runs.
|
|
50
|
+
|
|
51
|
+
## Inspecting And Answering Requests
|
|
52
|
+
|
|
53
|
+
Use the **same host, account, data lane, agent kind, and optional worker ID flags** on every command.
|
|
54
|
+
They select the saved identity. `ai workers` shows online workers in the authenticated scope;
|
|
55
|
+
`ai work` shows this worker's pending requests and renews its heartbeat.
|
|
56
|
+
After 90 seconds without a heartbeat, a worker stops receiving new requests. Its registration stays
|
|
57
|
+
available for 24 hours so a delayed authenticated poll can recover. Released or expired registrations
|
|
58
|
+
require an explicit restart/register; polling cannot revive them.
|
|
59
|
+
|
|
60
|
+
For an existing local interactive agent, start `ai serve --agent manual` in another terminal.
|
|
61
|
+
Then use:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
remits-cli ai work --agent manual --account-id 1 --base-url http://localhost:8080
|
|
65
|
+
remits-cli ai request --agent manual --account-id 1 --base-url http://localhost:8080 --request-id REQUEST_ID
|
|
66
|
+
remits-cli ai respond --agent manual --account-id 1 --base-url http://localhost:8080 --request-id REQUEST_ID --input-file decision.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The request includes the exact outgoing `messages`, tools, tool choice, response-format requirements,
|
|
70
|
+
and Remits `sessionId`. Act as the receiving model. **Do not execute the workflow tool yourself**:
|
|
71
|
+
return its name and arguments. Remits executes it in the original invocation and delivers the next round.
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{"content":null,"toolCalls":[{"name":"record","arguments":{"value":7}}]}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For a final answer: `{"content":"Done","toolCalls":[]}`. For a terminal failure:
|
|
78
|
+
`{"error":"The local agent cannot handle this request"}`. Optional `nativeSessionId` and `model` annotate
|
|
79
|
+
the response. The server rejects unavailable tools, invalid required choices, expired requests, and
|
|
80
|
+
conflicting duplicate replies. `ai status --request-id ID` inspects settlement without retrieving content.
|
|
81
|
+
|
|
82
|
+
## Sessions And Recovery
|
|
83
|
+
|
|
84
|
+
Each Remits `sessionId` uses one native conversation. Subsequent tool rounds explicitly resume its
|
|
85
|
+
saved Codex thread or Claude/Gemini session ID. `ai sessions` shows the mapping and local state directory.
|
|
86
|
+
It survives an ordinary supervisor restart. `ai release` stops dispatch to that worker; the running
|
|
87
|
+
supervisor observes release and exits. No automatic migration to a different worker occurs.
|
|
88
|
+
|
|
89
|
+
Missed WebSocket notifications recover through authenticated polling. Finished decisions are saved
|
|
90
|
+
before submission, so an HTTP retry does not repeat inference. If the supervisor died during inference,
|
|
91
|
+
the native outcome is uncertain: inspect the native session and use a new Remits session rather than
|
|
92
|
+
deleting the journal and accidentally replaying a tool decision. Never run competing supervisors with
|
|
93
|
+
the same worker ID.
|
|
94
|
+
|
|
95
|
+
Local state is under `~/.remits-cli/ai/<scope hash>/`: `worker.json`, `sessions/`, `requests/`, and isolated
|
|
96
|
+
`workspaces/`. The hash includes host, user, account, lane, agent kind, and optional worker ID. Files may
|
|
97
|
+
contain sensitive workflow data. Native tools keep their own conversation history separately.
|
|
98
|
+
|
|
99
|
+
## Limits And Evidence
|
|
100
|
+
|
|
101
|
+
Native login failures are native setup issues. In particular, Gemini can refuse an account or client
|
|
102
|
+
tier before inference; registering the Remits worker does not establish native eligibility.
|
|
103
|
+
|
|
104
|
+
Automatic adapters accept text requests; unsupported image/audio/file parts fail clearly. Manual
|
|
105
|
+
workers receive the complete packet. The packet is identical to the provider-bound request, but the
|
|
106
|
+
native agent's own harness and retained history differ. Pruned content may remain in native memory.
|
|
107
|
+
Use this for initial workflow tuning, then validate the finished workflow on its configured provider.
|
|
108
|
+
|
|
109
|
+
No OpenRouter request occurs for a routed round. Native subscription/API usage may still apply.
|
|
110
|
+
Telemetry labels local providers and leaves native cost unknown. `fallback:'provider'` is explicit and
|
|
111
|
+
only applies before dispatch; no local timeout or ambiguous response silently spends on OpenRouter.
|