severally-mcp 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +17 -7
- package/README.md +17 -7
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +13 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +10669 -10404
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +259 -86
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +72 -24
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +320 -152
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +97 -27
- package/src/redact.mjs +38 -14
- package/src/run.mjs +67 -18
- package/src/schema.mjs +50 -18
- package/src/server.mjs +134 -72
- package/src/status.mjs +81 -12
- package/src/store.mjs +131 -29
package/src/jobs.mjs
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
POLICY,
|
|
9
|
+
VERDICTS,
|
|
10
|
+
limitsSummary,
|
|
11
|
+
timeoutMs,
|
|
12
|
+
detectCaller,
|
|
13
|
+
configProblem,
|
|
14
|
+
consultantByName,
|
|
15
|
+
consultantAvailable,
|
|
16
|
+
} from './policy.mjs';
|
|
8
17
|
import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
|
|
9
18
|
import { parseRequest, RequestError, charCount } from './schema.mjs';
|
|
10
19
|
import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
|
|
@@ -71,13 +80,20 @@ function ownerState(jobId, owner) {
|
|
|
71
80
|
if (!owner) return 'dead';
|
|
72
81
|
let pidAlive;
|
|
73
82
|
if (owner.pid === process.pid) pidAlive = LIVE_HERE.has(jobId);
|
|
74
|
-
else
|
|
83
|
+
else
|
|
84
|
+
try {
|
|
85
|
+
process.kill(owner.pid, 0);
|
|
86
|
+
pidAlive = true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
pidAlive = err.code === 'EPERM';
|
|
89
|
+
}
|
|
75
90
|
if (!pidAlive) return 'dead';
|
|
76
91
|
return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
export class JobManager {
|
|
80
95
|
constructor() {
|
|
96
|
+
this._cancelWatch = null;
|
|
81
97
|
this.jobs = new Map();
|
|
82
98
|
this.groups = new Map();
|
|
83
99
|
this.order = [];
|
|
@@ -103,7 +119,10 @@ export class JobManager {
|
|
|
103
119
|
// owner finished and died before removing the marker.
|
|
104
120
|
#interrupt(jobId, rec, state) {
|
|
105
121
|
const done = store.loadRound(jobId);
|
|
106
|
-
if (done) {
|
|
122
|
+
if (done) {
|
|
123
|
+
store.removeRunning(jobId);
|
|
124
|
+
return done;
|
|
125
|
+
}
|
|
107
126
|
const { owner, ...round } = rec;
|
|
108
127
|
round.status = 'failed';
|
|
109
128
|
round.finished_at = new Date().toISOString();
|
|
@@ -114,7 +133,11 @@ export class JobManager {
|
|
|
114
133
|
retriable: true,
|
|
115
134
|
delivered_advice: false,
|
|
116
135
|
};
|
|
117
|
-
try {
|
|
136
|
+
try {
|
|
137
|
+
store.persistRound(round);
|
|
138
|
+
} catch {
|
|
139
|
+
return round;
|
|
140
|
+
}
|
|
118
141
|
store.removeRunning(jobId);
|
|
119
142
|
// An overdue owner may still be running its consultant: leave its working
|
|
120
143
|
// directory to it. ponytail: if the pid was reused instead, that
|
|
@@ -195,6 +218,7 @@ export class JobManager {
|
|
|
195
218
|
|
|
196
219
|
// `detail: "full"` returns the brief as stored; by default a finished
|
|
197
220
|
// consultation names it (size, sha256, round file) instead of repeating it.
|
|
221
|
+
/** @param {string} jobId @param {{detail?: string}} [opts] */
|
|
198
222
|
view(jobId, { detail } = {}) {
|
|
199
223
|
const job = this.jobs.get(jobId);
|
|
200
224
|
if (!job) {
|
|
@@ -206,9 +230,10 @@ export class JobManager {
|
|
|
206
230
|
// While it runs, say what it is doing. "running" for twenty minutes tells
|
|
207
231
|
// the lead nothing; a consultation visibly grinding through tool calls is
|
|
208
232
|
// one it can cancel now rather than wait out.
|
|
209
|
-
rec.progress =
|
|
210
|
-
|
|
211
|
-
|
|
233
|
+
rec.progress =
|
|
234
|
+
job.status === 'running' && job._handle
|
|
235
|
+
? (ADAPTERS[job.cli]?.progress?.({ stdout: job._handle.stdoutSoFar() }) ?? null)
|
|
236
|
+
: null;
|
|
212
237
|
rec.record = job.record ? recordSummary(job) : null;
|
|
213
238
|
rec.prediction = job.prediction ?? null;
|
|
214
239
|
rec.reflection = job.reflection ?? null;
|
|
@@ -222,7 +247,8 @@ export class JobManager {
|
|
|
222
247
|
const live = job.status === 'running' || job.status === 'queued' || job.status === 'cancelling';
|
|
223
248
|
if (live) {
|
|
224
249
|
rec.brief = null;
|
|
225
|
-
rec.brief_note =
|
|
250
|
+
rec.brief_note =
|
|
251
|
+
'omitted while the consultation is running; returned once it finishes, and kept in ~/.severally/history either way';
|
|
226
252
|
}
|
|
227
253
|
rec.limits = live ? null : limitsSummary();
|
|
228
254
|
rec.next_step = nextStep(job);
|
|
@@ -246,41 +272,46 @@ export class JobManager {
|
|
|
246
272
|
const state = ownerState(jobId, live.owner);
|
|
247
273
|
// A round saved just before its owner died is the answer, whatever the marker says.
|
|
248
274
|
const saved = state === 'alive' ? null : store.loadRoundAt(live);
|
|
249
|
-
elsewhere.push({
|
|
275
|
+
elsewhere.push({
|
|
276
|
+
rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
|
|
277
|
+
});
|
|
250
278
|
}
|
|
251
|
-
for (const row of store.indexRows())
|
|
279
|
+
for (const row of store.indexRows())
|
|
280
|
+
if (!seen.has(row.job_id)) {
|
|
281
|
+
seen.add(row.job_id);
|
|
282
|
+
elsewhere.push({ row });
|
|
283
|
+
}
|
|
252
284
|
const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
|
|
253
285
|
const picked = [];
|
|
254
286
|
// Round files are read only for the rows shown; one that cannot be read is passed over.
|
|
255
287
|
for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
|
|
256
288
|
if (picked.length >= limit) break;
|
|
257
|
-
const j = x.row ? store.loadRoundAt(x.row) : x.rec ?? x;
|
|
289
|
+
const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
|
|
258
290
|
if (j) picked.push(j);
|
|
259
291
|
}
|
|
260
|
-
return picked
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
});
|
|
292
|
+
return picked.map((j) => {
|
|
293
|
+
return {
|
|
294
|
+
job_id: j.job_id,
|
|
295
|
+
group_id: j.group_id ?? null,
|
|
296
|
+
chain_id: j.chain_id,
|
|
297
|
+
round: j.round,
|
|
298
|
+
target: j.target,
|
|
299
|
+
cli: j.cli,
|
|
300
|
+
model: j.model,
|
|
301
|
+
mode: j.mode,
|
|
302
|
+
status: j.status,
|
|
303
|
+
failure_kind: j.failure?.kind ?? null,
|
|
304
|
+
created_at: j.created_at,
|
|
305
|
+
duration_ms: j.duration_ms,
|
|
306
|
+
summary: j.result ? j.result.summary.slice(0, 200) : null,
|
|
307
|
+
recorded: Boolean(j.record),
|
|
308
|
+
verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
|
|
309
|
+
this_session: this.jobs.has(j.job_id),
|
|
310
|
+
predicted: Boolean(j.prediction),
|
|
311
|
+
reflected: Boolean(j.reflection),
|
|
312
|
+
initiator: j.initiator ?? null,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
284
315
|
}
|
|
285
316
|
|
|
286
317
|
/**
|
|
@@ -300,7 +331,11 @@ export class JobManager {
|
|
|
300
331
|
t.consultations += 1;
|
|
301
332
|
t.attempts += attempts.length;
|
|
302
333
|
for (const u of attempts) {
|
|
303
|
-
for (const [k, v] of Object.entries(u))
|
|
334
|
+
for (const [k, v] of Object.entries(u))
|
|
335
|
+
if (typeof v === 'number') {
|
|
336
|
+
t[k] = (t[k] ?? 0) + v;
|
|
337
|
+
seen.add(k);
|
|
338
|
+
}
|
|
304
339
|
}
|
|
305
340
|
}
|
|
306
341
|
for (const [target, t] of Object.entries(by)) {
|
|
@@ -318,12 +353,12 @@ export class JobManager {
|
|
|
318
353
|
}
|
|
319
354
|
// A config the operator got wrong is not run on defaults: that could
|
|
320
355
|
// re-enable a CLI they turned off. Reading the history still works.
|
|
321
|
-
if (configProblem())
|
|
356
|
+
if (configProblem())
|
|
357
|
+
throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, 'config_invalid');
|
|
322
358
|
|
|
323
359
|
// Resolve the chain before validating, so follow-up rules can be applied.
|
|
324
|
-
const followupTo =
|
|
325
|
-
? rawRequest.followup_to.trim()
|
|
326
|
-
: null;
|
|
360
|
+
const followupTo =
|
|
361
|
+
typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
|
|
327
362
|
let parent = null;
|
|
328
363
|
let fromGroup = null;
|
|
329
364
|
if (followupTo?.startsWith('group_')) {
|
|
@@ -367,7 +402,8 @@ export class JobManager {
|
|
|
367
402
|
rawRequest = { ...rawRequest, target: parent.target };
|
|
368
403
|
}
|
|
369
404
|
// A fan-out's members were all handed the same brief, so any one of them holds it.
|
|
370
|
-
if (parent || fromGroup)
|
|
405
|
+
if (parent || fromGroup)
|
|
406
|
+
rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
|
|
371
407
|
|
|
372
408
|
const req = parseRequest(rawRequest, {
|
|
373
409
|
isFollowup: Boolean(parent || fromGroup),
|
|
@@ -411,23 +447,25 @@ export class JobManager {
|
|
|
411
447
|
// not, so a suspended laptop must not make a live owner look overdue.
|
|
412
448
|
owner: {
|
|
413
449
|
pid: process.pid,
|
|
414
|
-
deadline_at: new Date(
|
|
450
|
+
deadline_at: new Date(
|
|
451
|
+
Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
|
|
452
|
+
).toISOString(),
|
|
415
453
|
},
|
|
416
454
|
}));
|
|
417
455
|
const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
|
|
418
456
|
if (taken.chainBusy) {
|
|
419
457
|
const { live, mine } = taken.chainBusy;
|
|
420
458
|
throw new RequestError(
|
|
421
|
-
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}`
|
|
422
|
-
|
|
459
|
+
`another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}` +
|
|
460
|
+
'; wait for it (consult_get) and follow up the latest round',
|
|
423
461
|
'followup_not_latest',
|
|
424
462
|
);
|
|
425
463
|
}
|
|
426
464
|
if (!taken.ok) {
|
|
427
465
|
throw new RequestError(
|
|
428
|
-
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
|
|
429
|
-
|
|
430
|
-
|
|
466
|
+
`this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}` +
|
|
467
|
+
(taken.elsewhere ? `, and ${taken.elsewhere} of the ${taken.live} running are in other severally processes` : '') +
|
|
468
|
+
'. Wait for one to finish (consult_get) or cancel it',
|
|
431
469
|
'concurrency_limit',
|
|
432
470
|
);
|
|
433
471
|
}
|
|
@@ -454,7 +492,11 @@ export class JobManager {
|
|
|
454
492
|
for (const jid of group.job_ids) {
|
|
455
493
|
const member = this.jobs.get(jid) ?? store.loadRound(jid);
|
|
456
494
|
if (!member) {
|
|
457
|
-
skipped.push({
|
|
495
|
+
skipped.push({
|
|
496
|
+
job_id: jid,
|
|
497
|
+
target: null,
|
|
498
|
+
reason: 'not found: aged out of the history, or still running in another severally process',
|
|
499
|
+
});
|
|
458
500
|
continue;
|
|
459
501
|
}
|
|
460
502
|
const rounds = this.#chainRounds(member.chain_id);
|
|
@@ -463,10 +505,14 @@ export class JobManager {
|
|
|
463
505
|
// The name is kept, not re-resolved: a consultant renamed or turned off
|
|
464
506
|
// since is skipped, never continued by whichever one answers to it now.
|
|
465
507
|
const consultant = consultantByName(member.target);
|
|
466
|
-
const reason = !latest
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
508
|
+
const reason = !latest
|
|
509
|
+
? `${member.status}, so there is no answer to follow up`
|
|
510
|
+
: latest.status !== 'completed'
|
|
511
|
+
? `round ${latest.round} is still ${latest.status}`
|
|
512
|
+
: nextRound > POLICY.maxRounds
|
|
513
|
+
? `has used its ${POLICY.maxRounds} rounds`
|
|
514
|
+
: !consultant || !consultantAvailable(consultant)
|
|
515
|
+
? 'this consultant is no longer configured or available here'
|
|
470
516
|
: null;
|
|
471
517
|
if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
|
|
472
518
|
else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
|
|
@@ -491,7 +537,7 @@ export class JobManager {
|
|
|
491
537
|
// missing or a tree that blows the cap is the lead's to fix now rather
|
|
492
538
|
// than a job that fails having already spent a round of this chain.
|
|
493
539
|
let exposeManifest = null;
|
|
494
|
-
const parentEntries = req.context.expose_from_parent ? parent.brief?.expose_paths?.entries ?? [] : [];
|
|
540
|
+
const parentEntries = req.context.expose_from_parent ? (parent.brief?.expose_paths?.entries ?? []) : [];
|
|
495
541
|
if (req.context.expose_from_parent && parentEntries.length === 0) {
|
|
496
542
|
throw new RequestError(
|
|
497
543
|
`context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
|
|
@@ -503,8 +549,11 @@ export class JobManager {
|
|
|
503
549
|
try {
|
|
504
550
|
fs.mkdirSync(path.join(snapshot, 'workspace'), { recursive: true, mode: 0o700 });
|
|
505
551
|
const plan = planInheritance(parentEntries, req.context.expose_paths);
|
|
506
|
-
exposeManifest = materializeExposedPaths(snapshot, plan.paths)
|
|
507
|
-
|
|
552
|
+
exposeManifest = materializeExposedPaths(snapshot, plan.paths) ?? {
|
|
553
|
+
entries: [],
|
|
554
|
+
totals: { files: 0, bytes: 0 },
|
|
555
|
+
skipped: [],
|
|
556
|
+
};
|
|
508
557
|
exposeManifest.skipped.push(...plan.skipped);
|
|
509
558
|
const parentSources = new Set(parentEntries.map((e) => e.source));
|
|
510
559
|
for (const e of exposeManifest.entries) if (parentSources.has(e.source)) e.inherited = true;
|
|
@@ -522,14 +571,21 @@ export class JobManager {
|
|
|
522
571
|
const room = Math.min(POLICY.input.artifactExcerptMax, POLICY.input.totalCharsMax - charCount(req));
|
|
523
572
|
const diff = exposeDiff(snapshot, req.context.expose_diff, exposeManifest, room);
|
|
524
573
|
if (diff) {
|
|
525
|
-
req.context.artifacts = [
|
|
526
|
-
|
|
574
|
+
req.context.artifacts = [
|
|
575
|
+
...req.context.artifacts,
|
|
576
|
+
{ name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff },
|
|
577
|
+
];
|
|
527
578
|
}
|
|
528
579
|
}
|
|
529
580
|
markSinceParent(exposeManifest, parentEntries);
|
|
530
581
|
// Every earlier path gone, and nothing else to go on: refused, as a
|
|
531
582
|
// first round with no context is.
|
|
532
|
-
if (
|
|
583
|
+
if (
|
|
584
|
+
parentEntries.length &&
|
|
585
|
+
!exposeManifest.entries.length &&
|
|
586
|
+
!req.context.facts.length &&
|
|
587
|
+
!req.context.artifacts.length
|
|
588
|
+
) {
|
|
533
589
|
throw new RequestError(
|
|
534
590
|
`context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
|
|
535
591
|
'nothing_to_inherit',
|
|
@@ -615,7 +671,11 @@ export class JobManager {
|
|
|
615
671
|
job_ids: members.map((m) => m.job_id),
|
|
616
672
|
};
|
|
617
673
|
this.groups.set(groupId, group);
|
|
618
|
-
try {
|
|
674
|
+
try {
|
|
675
|
+
store.persistGroup(group);
|
|
676
|
+
} catch {
|
|
677
|
+
/* history is best effort */
|
|
678
|
+
}
|
|
619
679
|
|
|
620
680
|
// A fan-out is only comparable if every consultant answered the same
|
|
621
681
|
// question in the same words: every member's brief is the same request,
|
|
@@ -646,9 +706,12 @@ export class JobManager {
|
|
|
646
706
|
// consultation, but one about code that hands over no file is one the
|
|
647
707
|
// consultant answers without having seen the repository.
|
|
648
708
|
files_exposed: exposeManifest?.totals.files ?? 0,
|
|
649
|
-
...(exposeManifest?.totals.files
|
|
650
|
-
|
|
651
|
-
|
|
709
|
+
...(exposeManifest?.totals.files
|
|
710
|
+
? {}
|
|
711
|
+
: {
|
|
712
|
+
files_note:
|
|
713
|
+
'no file was handed over, so the consultant answers from the brief alone; if this is about code in a repository, consult_cancel and start again with context.expose_paths',
|
|
714
|
+
}),
|
|
652
715
|
status: 'running',
|
|
653
716
|
accepted_at: group.created_at,
|
|
654
717
|
limits: limitsSummary(),
|
|
@@ -670,7 +733,11 @@ export class JobManager {
|
|
|
670
733
|
...common,
|
|
671
734
|
question: group.question, // the redacted text every member was actually sent
|
|
672
735
|
jobs: members.map((m) => ({
|
|
673
|
-
job_id: m.job_id,
|
|
736
|
+
job_id: m.job_id,
|
|
737
|
+
chain_id: m.chain_id,
|
|
738
|
+
target: m.target,
|
|
739
|
+
cli: m.cli,
|
|
740
|
+
model: m.model,
|
|
674
741
|
...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
|
|
675
742
|
})),
|
|
676
743
|
poll_with: `consult_get({ group_id: "${groupId}" })`,
|
|
@@ -696,10 +763,13 @@ export class JobManager {
|
|
|
696
763
|
const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
|
|
697
764
|
// The checked points first, then the unchecked ones, each with the
|
|
698
765
|
// lead's verdict and effect when there is one.
|
|
699
|
-
const ordered = (items, shape) =>
|
|
700
|
-
...items.filter((x) => verdicts.has(x.id)),
|
|
701
|
-
|
|
702
|
-
|
|
766
|
+
const ordered = (items, shape) =>
|
|
767
|
+
[...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
|
|
768
|
+
id: x.id,
|
|
769
|
+
...shape(x),
|
|
770
|
+
verdict: verdicts.get(x.id)?.verdict ?? null,
|
|
771
|
+
effect: verdicts.get(x.id)?.effect ?? null,
|
|
772
|
+
}));
|
|
703
773
|
const checked = (x) => x.verdict !== null;
|
|
704
774
|
const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
|
|
705
775
|
return {
|
|
@@ -797,11 +867,11 @@ export class JobManager {
|
|
|
797
867
|
return this.#fail(
|
|
798
868
|
job,
|
|
799
869
|
'auth',
|
|
800
|
-
`no ${t.label} credential to hand the consultant: nothing at `
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
870
|
+
`no ${t.label} credential to hand the consultant: nothing at ` +
|
|
871
|
+
`${sandbox.credentialsSource}, and none of ${ALL_API_CREDENTIAL_VARS.join(' / ')} names a ` +
|
|
872
|
+
'usable credential. Log in with that CLI, point SEVERALLY_AGY_CRED_HOME at the home ' +
|
|
873
|
+
'directory that holds the token, or set one of those variables to authenticate with an API key ' +
|
|
874
|
+
'instead.',
|
|
805
875
|
);
|
|
806
876
|
}
|
|
807
877
|
|
|
@@ -833,8 +903,9 @@ export class JobManager {
|
|
|
833
903
|
// What this consultant was actually launched with, read off the
|
|
834
904
|
// invocation and the sandbox rather than restated from intent, so the
|
|
835
905
|
// record says what held for this run.
|
|
836
|
-
job.boundary =
|
|
837
|
-
hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
906
|
+
job.boundary =
|
|
907
|
+
adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
|
|
908
|
+
null;
|
|
838
909
|
|
|
839
910
|
const attempt = (timeout) => {
|
|
840
911
|
const { handle, done } = runChild({
|
|
@@ -876,7 +947,9 @@ export class JobManager {
|
|
|
876
947
|
if ((run.stalled || run.timedOut || run.cancelled) && adapter.diagnostics && sandbox) {
|
|
877
948
|
try {
|
|
878
949
|
job.diagnostics = store.writeDiagnostics(job.chain_id, job.round, redact(adapter.diagnostics({ run, sandbox })));
|
|
879
|
-
} catch {
|
|
950
|
+
} catch {
|
|
951
|
+
/* history is best effort */
|
|
952
|
+
}
|
|
880
953
|
}
|
|
881
954
|
if (run.spawnError) {
|
|
882
955
|
return this.#fail(job, 'spawn_error', `could not start ${invocation.command}: ${run.spawnError}`);
|
|
@@ -911,9 +984,12 @@ export class JobManager {
|
|
|
911
984
|
// No envelope at all: what stderr holds is the dump from stopping it,
|
|
912
985
|
// not a message to classify. Not re-run, as it may have spent its quota.
|
|
913
986
|
if (run.stalled && !interpreted.usageRaw) {
|
|
914
|
-
return this.#fail(
|
|
987
|
+
return this.#fail(
|
|
988
|
+
job,
|
|
989
|
+
'cli_error',
|
|
915
990
|
`${invocation.command} wrote its finish step but neither returned a result nor exited, and was stopped`,
|
|
916
|
-
adapter.progress?.(run) ?? null
|
|
991
|
+
adapter.progress?.(run) ?? null,
|
|
992
|
+
);
|
|
917
993
|
}
|
|
918
994
|
|
|
919
995
|
const remaining = budgetMs - (Date.now() - t0);
|
|
@@ -921,19 +997,35 @@ export class JobManager {
|
|
|
921
997
|
authTried = true;
|
|
922
998
|
const seeded = adapter.recoverAuthFailure({ interpreted, sandbox, model: job.model });
|
|
923
999
|
if (seeded && remaining >= MIN_RETRY_MS) {
|
|
924
|
-
job.retries.push({
|
|
1000
|
+
job.retries.push({
|
|
1001
|
+
failure_kind: interpreted.failureKind,
|
|
1002
|
+
reason: 'credentials re-seeded',
|
|
1003
|
+
at: new Date().toISOString(),
|
|
1004
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1005
|
+
});
|
|
925
1006
|
run = await attempt(remaining);
|
|
926
1007
|
continue;
|
|
927
1008
|
}
|
|
928
1009
|
}
|
|
929
1010
|
const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
|
|
930
|
-
if (
|
|
931
|
-
|
|
1011
|
+
if (
|
|
1012
|
+
AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
|
|
1013
|
+
autoRetries < POLICY.maxAutoRetries &&
|
|
1014
|
+
remaining - wait >= MIN_RETRY_MS
|
|
1015
|
+
) {
|
|
932
1016
|
autoRetries += 1;
|
|
933
|
-
job.retries.push({
|
|
1017
|
+
job.retries.push({
|
|
1018
|
+
failure_kind: interpreted.failureKind,
|
|
1019
|
+
reason: `transient; re-run after ${wait} ms`,
|
|
1020
|
+
at: new Date().toISOString(),
|
|
1021
|
+
usage: adapter.usageRecord(interpreted.usageRaw),
|
|
1022
|
+
});
|
|
934
1023
|
await new Promise((resolve) => {
|
|
935
1024
|
const timer = setTimeout(resolve, wait);
|
|
936
|
-
job._cancelFns.push(() => {
|
|
1025
|
+
job._cancelFns.push(() => {
|
|
1026
|
+
clearTimeout(timer);
|
|
1027
|
+
resolve();
|
|
1028
|
+
});
|
|
937
1029
|
});
|
|
938
1030
|
if (job.cancelRequested) return this.#fail(job, 'cancelled', 'consultation cancelled by the lead');
|
|
939
1031
|
run = await attempt(budgetMs - (Date.now() - t0));
|
|
@@ -943,8 +1035,8 @@ export class JobManager {
|
|
|
943
1035
|
}
|
|
944
1036
|
|
|
945
1037
|
// Without an answer, a process left holding the pipes is the failure to report.
|
|
946
|
-
const leftover =
|
|
947
|
-
&& `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
1038
|
+
const leftover =
|
|
1039
|
+
run.pipesForced && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
|
|
948
1040
|
if (!interpreted.ok) {
|
|
949
1041
|
job.usage = adapter.usageRecord(interpreted.usageRaw);
|
|
950
1042
|
if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
|
|
@@ -983,8 +1075,10 @@ export class JobManager {
|
|
|
983
1075
|
const notes = [
|
|
984
1076
|
adviceCaveat(normalized),
|
|
985
1077
|
sameVendorCaveat(job.caller, job.caller_model, consultant),
|
|
986
|
-
interpreted.recoveredError &&
|
|
987
|
-
|
|
1078
|
+
interpreted.recoveredError &&
|
|
1079
|
+
`the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
|
|
1080
|
+
leftover &&
|
|
1081
|
+
'the answer parsed, but a process the consultant started kept its output open after it exited and was stopped (one that left its process group may still be running)',
|
|
988
1082
|
].filter(Boolean);
|
|
989
1083
|
// No `advice_usable` verdict here. It was always true, so it carried no
|
|
990
1084
|
// information -- and worse, it read as the server vouching for advice it
|
|
@@ -1024,11 +1118,19 @@ export class JobManager {
|
|
|
1024
1118
|
|
|
1025
1119
|
// The in-progress marker other processes read (see store.writeRunning).
|
|
1026
1120
|
#mark(job) {
|
|
1027
|
-
try {
|
|
1121
|
+
try {
|
|
1122
|
+
store.writeRunning({ ...this.#record(job), owner: job._owner });
|
|
1123
|
+
} catch {
|
|
1124
|
+
/* history is best effort */
|
|
1125
|
+
}
|
|
1028
1126
|
}
|
|
1029
1127
|
|
|
1030
1128
|
#finish(job) {
|
|
1031
|
-
try {
|
|
1129
|
+
try {
|
|
1130
|
+
store.persistRound(this.#record(job));
|
|
1131
|
+
} catch {
|
|
1132
|
+
/* history is best effort */
|
|
1133
|
+
}
|
|
1032
1134
|
store.removeRunning(job.job_id);
|
|
1033
1135
|
store.releaseSeat(job.job_id);
|
|
1034
1136
|
LIVE_HERE.delete(job.job_id);
|
|
@@ -1043,16 +1145,17 @@ export class JobManager {
|
|
|
1043
1145
|
// consultant did not produce and a word outside VERDICTS, then stores what
|
|
1044
1146
|
// was written. It never decides that a finding was adopted, and it never
|
|
1045
1147
|
// fills in a verdict the lead did not write.
|
|
1148
|
+
/** @param {Record<string, any>} [input] */
|
|
1046
1149
|
record(input = {}) {
|
|
1047
1150
|
if (input.records !== undefined) return this.#recordMany(input);
|
|
1048
1151
|
const allowed = ['job_id', 'entries', 'reflection'];
|
|
1049
1152
|
const unknown = Object.keys(input).filter((k) => !allowed.includes(k));
|
|
1050
1153
|
if (unknown.length) {
|
|
1051
1154
|
throw new RequestError(
|
|
1052
|
-
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records`
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1155
|
+
`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here; this call takes ${allowed.join(', ')}, or records` +
|
|
1156
|
+
(unknown.includes('prediction')
|
|
1157
|
+
? ". A prediction is written when the consultation starts (consult_start's `prediction`) and cannot be added or changed afterwards -- that is what makes it a prediction"
|
|
1158
|
+
: ''),
|
|
1056
1159
|
'unknown_field',
|
|
1057
1160
|
);
|
|
1058
1161
|
}
|
|
@@ -1069,7 +1172,10 @@ export class JobManager {
|
|
|
1069
1172
|
#recordMany(input) {
|
|
1070
1173
|
const extra = Object.keys(input).filter((k) => k !== 'records');
|
|
1071
1174
|
if (extra.length) {
|
|
1072
|
-
throw new RequestError(
|
|
1175
|
+
throw new RequestError(
|
|
1176
|
+
`records cannot be combined with ${extra.map((k) => `"${k}"`).join(', ')}; put each job's verdicts inside records`,
|
|
1177
|
+
'unknown_field',
|
|
1178
|
+
);
|
|
1073
1179
|
}
|
|
1074
1180
|
const { records } = input;
|
|
1075
1181
|
if (!Array.isArray(records) || records.length === 0) {
|
|
@@ -1080,10 +1186,12 @@ export class JobManager {
|
|
|
1080
1186
|
const seen = new Set();
|
|
1081
1187
|
records.forEach((r, i) => {
|
|
1082
1188
|
try {
|
|
1083
|
-
if (seen.has(r?.job_id))
|
|
1189
|
+
if (seen.has(r?.job_id))
|
|
1190
|
+
throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
|
|
1084
1191
|
seen.add(r?.job_id);
|
|
1085
1192
|
const unknown = Object.keys(r ?? {}).filter((k) => !['job_id', 'entries', 'reflection'].includes(k));
|
|
1086
|
-
if (unknown.length)
|
|
1193
|
+
if (unknown.length)
|
|
1194
|
+
throw new RequestError(`${unknown.map((k) => `"${k}"`).join(', ')} cannot be written here`, 'unknown_field');
|
|
1087
1195
|
changes.push(this.#checkRecord(r ?? {}));
|
|
1088
1196
|
} catch (err) {
|
|
1089
1197
|
if (!(err instanceof RequestError)) throw err;
|
|
@@ -1104,9 +1212,13 @@ export class JobManager {
|
|
|
1104
1212
|
|
|
1105
1213
|
// Everything that can be wrong with a record, found before anything is
|
|
1106
1214
|
// written: what the job's record and reflection become, or a RequestError.
|
|
1215
|
+
/** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
|
|
1107
1216
|
#checkRecord({ job_id: jobId, entries, reflection }) {
|
|
1108
1217
|
if (entries === undefined && reflection === undefined) {
|
|
1109
|
-
throw new RequestError(
|
|
1218
|
+
throw new RequestError(
|
|
1219
|
+
'pass entries (verdicts on specific points), reflection (what the answer added), or both',
|
|
1220
|
+
'nothing_to_record',
|
|
1221
|
+
);
|
|
1110
1222
|
}
|
|
1111
1223
|
// A job this process ran is in memory; one from an earlier session is read
|
|
1112
1224
|
// back from its round file, so a verdict can still be written once the
|
|
@@ -1114,10 +1226,7 @@ export class JobManager {
|
|
|
1114
1226
|
const live = this.jobs.get(jobId);
|
|
1115
1227
|
const job = live ?? store.loadRound(jobId);
|
|
1116
1228
|
if (!job) {
|
|
1117
|
-
throw new RequestError(
|
|
1118
|
-
`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`,
|
|
1119
|
-
'unknown_job',
|
|
1120
|
-
);
|
|
1229
|
+
throw new RequestError(`no consultation with job_id "${jobId}" in this session or in ~/.severally/history`, 'unknown_job');
|
|
1121
1230
|
}
|
|
1122
1231
|
if (!job.result) {
|
|
1123
1232
|
throw new RequestError(
|
|
@@ -1180,9 +1289,10 @@ export class JobManager {
|
|
|
1180
1289
|
newReflection = { delta, related_item_ids: related, recorded_at: now };
|
|
1181
1290
|
}
|
|
1182
1291
|
|
|
1183
|
-
const newRecord =
|
|
1184
|
-
|
|
1185
|
-
|
|
1292
|
+
const newRecord =
|
|
1293
|
+
entries === undefined
|
|
1294
|
+
? undefined
|
|
1295
|
+
: { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
|
|
1186
1296
|
return { job, live, record: newRecord, reflection: newReflection };
|
|
1187
1297
|
}
|
|
1188
1298
|
|
|
@@ -1259,7 +1369,8 @@ export class JobManager {
|
|
|
1259
1369
|
if (!job) {
|
|
1260
1370
|
const view = this.view(jobId);
|
|
1261
1371
|
if (!view) return null;
|
|
1262
|
-
if (view.status !== 'running' && view.status !== 'queued')
|
|
1372
|
+
if (view.status !== 'running' && view.status !== 'queued')
|
|
1373
|
+
return { ...view, cancel_effect: `job was already ${view.status}` };
|
|
1263
1374
|
return {
|
|
1264
1375
|
...view,
|
|
1265
1376
|
cancel_effect: store.requestCancel(jobId)
|
|
@@ -1279,6 +1390,7 @@ export class JobManager {
|
|
|
1279
1390
|
// Every field here is derived from the one `members` array it is handed, so a
|
|
1280
1391
|
// group view can never report one status in `members` and another in
|
|
1281
1392
|
// `comparison` or `status` for the same job.
|
|
1393
|
+
/** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
|
|
1282
1394
|
#shapeGroup(group, members, { detail } = {}) {
|
|
1283
1395
|
const full = detail === 'full';
|
|
1284
1396
|
const cancelling = members.some((m) => m.status === 'cancelling');
|
|
@@ -1295,9 +1407,10 @@ export class JobManager {
|
|
|
1295
1407
|
// same thing three times. Hoist one copy to the group and strip it from
|
|
1296
1408
|
// the members, which keeps the payload readable without losing anything.
|
|
1297
1409
|
const firstBrief = members.find((m) => m.brief)?.brief ?? null;
|
|
1298
|
-
const groupBrief =
|
|
1299
|
-
|
|
1300
|
-
|
|
1410
|
+
const groupBrief =
|
|
1411
|
+
firstBrief && !full
|
|
1412
|
+
? { ...firstBrief, full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" }) or consult_export` }
|
|
1413
|
+
: firstBrief;
|
|
1301
1414
|
// The limits are the server's, not a member's: one copy, on the group.
|
|
1302
1415
|
// By default a member is only its state: what there is to compare is in
|
|
1303
1416
|
// `comparison`, once, and the full answer is one consult_get({ job_id }) away.
|
|
@@ -1305,7 +1418,8 @@ export class JobManager {
|
|
|
1305
1418
|
if (full) return rest;
|
|
1306
1419
|
const message = rest.failure && cut(rest.failure.message, FAILURE_MESSAGE_CHARS);
|
|
1307
1420
|
const progress = cut(rest.progress, FAILURE_MESSAGE_CHARS);
|
|
1308
|
-
const cutShort =
|
|
1421
|
+
const cutShort =
|
|
1422
|
+
progress !== rest.progress || Boolean(rest.failure && (message !== rest.failure.message || rest.failure.detail));
|
|
1309
1423
|
return {
|
|
1310
1424
|
job_id: rest.job_id,
|
|
1311
1425
|
target: rest.target,
|
|
@@ -1321,19 +1435,22 @@ export class JobManager {
|
|
|
1321
1435
|
const question = full ? group.question : cut(group.question, QUESTION_CHARS);
|
|
1322
1436
|
const view = {
|
|
1323
1437
|
group_id: group.group_id,
|
|
1324
|
-
status: cancelling ? 'cancelling' :
|
|
1438
|
+
status: cancelling ? 'cancelling' : live ? 'running' : 'done',
|
|
1325
1439
|
mode: group.mode,
|
|
1326
1440
|
question,
|
|
1327
|
-
...(question !== group.question
|
|
1441
|
+
...(question !== group.question
|
|
1442
|
+
? { question_full_with: `consult_get({ group_id: "${group.group_id}", detail: "full" })` }
|
|
1443
|
+
: {}),
|
|
1328
1444
|
brief: groupBrief,
|
|
1329
1445
|
members_expected: group.job_ids.length,
|
|
1330
1446
|
members_available: members.length,
|
|
1331
1447
|
// History is capped, so an older member can already be gone. Say so:
|
|
1332
1448
|
// a fan-out that quietly reports fewer answers than it asked for is
|
|
1333
1449
|
// worse than one that admits the comparison is partial.
|
|
1334
|
-
incomplete_note:
|
|
1335
|
-
|
|
1336
|
-
|
|
1450
|
+
incomplete_note:
|
|
1451
|
+
missing > 0
|
|
1452
|
+
? `${missing} of ${group.job_ids.length} member consultation(s) have aged out of this session's history; the comparison below is partial`
|
|
1453
|
+
: null,
|
|
1337
1454
|
members: trimmed,
|
|
1338
1455
|
comparison: null,
|
|
1339
1456
|
cancellable: live,
|
|
@@ -1383,10 +1500,14 @@ export class JobManager {
|
|
|
1383
1500
|
if (!view) return null;
|
|
1384
1501
|
// Started by another server: each member still running there is asked to stop.
|
|
1385
1502
|
const asked = (store.loadGroup(groupId)?.job_ids ?? [])
|
|
1386
|
-
.map((jid) => this.cancel(jid))
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1503
|
+
.map((jid) => this.cancel(jid))
|
|
1504
|
+
.filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
|
|
1505
|
+
return {
|
|
1506
|
+
...view,
|
|
1507
|
+
cancel_effect: asked
|
|
1508
|
+
? `asked the severally process running it to stop ${asked} consultation(s); that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
|
|
1509
|
+
: 'nothing to signal: no consultant in this fan-out is still running',
|
|
1510
|
+
};
|
|
1390
1511
|
}
|
|
1391
1512
|
// Count what is actually about to be signalled before signalling it:
|
|
1392
1513
|
// cancel() short-circuits on a member that has already finished, so a
|
|
@@ -1402,11 +1523,12 @@ export class JobManager {
|
|
|
1402
1523
|
const total = group.job_ids.length;
|
|
1403
1524
|
return {
|
|
1404
1525
|
...this.#shapeGroup(group, members),
|
|
1405
|
-
cancel_effect:
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1526
|
+
cancel_effect:
|
|
1527
|
+
signalled === 0
|
|
1528
|
+
? 'nothing to signal: every consultant in this fan-out had already finished'
|
|
1529
|
+
: signalled === total
|
|
1530
|
+
? 'every consultant process group in this fan-out was signalled'
|
|
1531
|
+
: `${signalled} of ${total} consultant process groups in this fan-out were signalled; the rest had already finished`,
|
|
1410
1532
|
};
|
|
1411
1533
|
}
|
|
1412
1534
|
|
|
@@ -1422,7 +1544,10 @@ export class JobManager {
|
|
|
1422
1544
|
for (const job of live) {
|
|
1423
1545
|
job.cancelRequested = true;
|
|
1424
1546
|
for (const fn of job._cancelFns) fn();
|
|
1425
|
-
if (job._handle) {
|
|
1547
|
+
if (job._handle) {
|
|
1548
|
+
handles.push(job._handle);
|
|
1549
|
+
job._handle.stop();
|
|
1550
|
+
}
|
|
1426
1551
|
}
|
|
1427
1552
|
const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
|
|
1428
1553
|
await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
|
|
@@ -1469,8 +1594,7 @@ function inheritedSummary(manifest) {
|
|
|
1469
1594
|
return {
|
|
1470
1595
|
entries: carried.map((e) => ({ exposed_as: e.exposedAs, kind: e.kind, files: e.files, changed: e.changed ?? null })),
|
|
1471
1596
|
// Only what an earlier round showed carries the name it had there.
|
|
1472
|
-
left_out: manifest.skipped.filter((x) => x.exposed_as)
|
|
1473
|
-
.map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1597
|
+
left_out: manifest.skipped.filter((x) => x.exposed_as).map((x) => ({ exposed_as: x.exposed_as, reason: x.reason })),
|
|
1474
1598
|
files: carried.reduce((n, e) => n + e.files, 0),
|
|
1475
1599
|
};
|
|
1476
1600
|
}
|
|
@@ -1572,7 +1696,13 @@ function inherit(raw, round) {
|
|
|
1572
1696
|
const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
|
|
1573
1697
|
if (!brief) return out;
|
|
1574
1698
|
for (const k of ['objective', 'success_criteria', 'constraints']) {
|
|
1575
|
-
if (
|
|
1699
|
+
if (
|
|
1700
|
+
out[k] === undefined &&
|
|
1701
|
+
raw.context?.[k] === undefined &&
|
|
1702
|
+
brief[k] != null &&
|
|
1703
|
+
!(Array.isArray(brief[k]) && !brief[k].length)
|
|
1704
|
+
)
|
|
1705
|
+
out[k] = brief[k];
|
|
1576
1706
|
}
|
|
1577
1707
|
const ctx = { ...(raw.context ?? {}) };
|
|
1578
1708
|
for (const k of ['facts', 'proposal', 'counterpoints']) {
|
|
@@ -1583,7 +1713,8 @@ function inherit(raw, round) {
|
|
|
1583
1713
|
// It is the last artifact (appended after the lead's own), so only that one is dropped.
|
|
1584
1714
|
const artifacts = [...(brief.artifacts ?? [])];
|
|
1585
1715
|
const last = artifacts.at(-1);
|
|
1586
|
-
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
|
|
1716
|
+
if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff '))
|
|
1717
|
+
artifacts.pop();
|
|
1587
1718
|
if (ctx.artifacts === undefined && artifacts.length) {
|
|
1588
1719
|
// Stored with null for an absent language or source; the request schema takes them as optional strings.
|
|
1589
1720
|
ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
|
|
@@ -1603,12 +1734,13 @@ function adviceCaveat(normalized) {
|
|
|
1603
1734
|
notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
|
|
1604
1735
|
}
|
|
1605
1736
|
// Read first in a comparison; a missing one is shown as null there, and named here.
|
|
1606
|
-
if (!result.stance)
|
|
1737
|
+
if (!result.stance)
|
|
1738
|
+
notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
|
|
1607
1739
|
if (quality.findings_without_grounds > 0) {
|
|
1608
1740
|
notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
|
|
1609
1741
|
}
|
|
1610
1742
|
if (quality.unsourced && result.findings.length > 0) {
|
|
1611
|
-
notes.push(
|
|
1743
|
+
notes.push("no sources were cited; the findings rest on the consultant's own reasoning over the brief");
|
|
1612
1744
|
}
|
|
1613
1745
|
return notes.length ? notes.join('; ') : null;
|
|
1614
1746
|
}
|
|
@@ -1623,7 +1755,7 @@ function adviceCaveat(normalized) {
|
|
|
1623
1755
|
function sameVendorCaveat(caller, callerModel, consultant) {
|
|
1624
1756
|
// The lead's lineage: its declared model if that names one, else its CLI's
|
|
1625
1757
|
// own. An unknown lineage on either side is no caveat -- not a guess.
|
|
1626
|
-
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? CLI_VENDORS[caller] ?? null : null);
|
|
1758
|
+
const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? (CLI_VENDORS[caller] ?? null) : null);
|
|
1627
1759
|
const vendor = consultant.vendor;
|
|
1628
1760
|
if (!callerVendor || !vendor || callerVendor !== vendor) return null;
|
|
1629
1761
|
// Say what a fresh session of the same lineage removes and what it keeps.
|
|
@@ -1667,7 +1799,7 @@ function comparison(members, budget) {
|
|
|
1667
1799
|
by_target: columns,
|
|
1668
1800
|
note:
|
|
1669
1801
|
'This server does not judge whether the consultants agree: similar summaries are not evidence of agreement. ' +
|
|
1670
|
-
|
|
1802
|
+
"Each stance is the consultant's own declaration, relayed as given -- consultants that share a finding can still " +
|
|
1671
1803
|
'declare opposite stances, and that is a divergence. Compare the grounds behind each point yourself, and spend a ' +
|
|
1672
1804
|
'follow-up only where a divergence would change your decision.',
|
|
1673
1805
|
};
|
|
@@ -1710,7 +1842,10 @@ function byTarget(m, budget) {
|
|
|
1710
1842
|
left -= n;
|
|
1711
1843
|
out.push(v);
|
|
1712
1844
|
}
|
|
1713
|
-
if (out.length < items.length) {
|
|
1845
|
+
if (out.length < items.length) {
|
|
1846
|
+
omitted[name] = items.length - out.length;
|
|
1847
|
+
truncated = true;
|
|
1848
|
+
}
|
|
1714
1849
|
return out;
|
|
1715
1850
|
};
|
|
1716
1851
|
const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
|
|
@@ -1720,15 +1855,31 @@ function byTarget(m, budget) {
|
|
|
1720
1855
|
// The findings first, with up to 60% of what is left and their points cut
|
|
1721
1856
|
// as short as it takes: they are what gets recorded against, so their ids
|
|
1722
1857
|
// matter most. The other lists then share the rest evenly.
|
|
1723
|
-
const findingPoints = list(
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1858
|
+
const findingPoints = list(
|
|
1859
|
+
'finding_points',
|
|
1860
|
+
r.findings,
|
|
1861
|
+
1,
|
|
1862
|
+
(f, n) => ({ id: f.id, severity: f.severity ?? null, point: clip(f.point, n) }),
|
|
1863
|
+
0.6,
|
|
1864
|
+
20,
|
|
1865
|
+
);
|
|
1866
|
+
const decisionChangers = list(
|
|
1867
|
+
'decision_changers',
|
|
1868
|
+
r.decision_changers,
|
|
1869
|
+
2,
|
|
1870
|
+
(d, n) => ({ condition: clip(d.condition, n), changes_to: clip(d.changes_to, n) }),
|
|
1871
|
+
1 / 4,
|
|
1872
|
+
);
|
|
1727
1873
|
const alternatives = list('alternative_options', r.alternatives, 1, (a, n) => clip(a.option, n), 1 / 3);
|
|
1728
1874
|
const unknowns = list('unknowns', r.unknowns, 1, (u, n) => ({ id: u.id, item: clip(u.item, n) }), 1 / 2);
|
|
1729
1875
|
const nextChecks = list('next_checks', r.next_checks, 1, (c, n) => ({ id: c.id, check: clip(c.check, n) }), 1 / 2);
|
|
1730
|
-
const disagreements = list(
|
|
1731
|
-
|
|
1876
|
+
const disagreements = list(
|
|
1877
|
+
'remaining_disagreements',
|
|
1878
|
+
r.remaining_disagreements,
|
|
1879
|
+
3,
|
|
1880
|
+
(d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
|
|
1881
|
+
1,
|
|
1882
|
+
);
|
|
1732
1883
|
const entry = {
|
|
1733
1884
|
job_id: m.job_id,
|
|
1734
1885
|
target: m.target,
|
|
@@ -1763,7 +1914,11 @@ function byTarget(m, budget) {
|
|
|
1763
1914
|
points_without_verdict: m.result ? (m.record?.unrecorded ?? recordableIds(m.result)).length : null,
|
|
1764
1915
|
};
|
|
1765
1916
|
// A member's status and failure are in `members`; say them once.
|
|
1766
|
-
if (!full) {
|
|
1917
|
+
if (!full) {
|
|
1918
|
+
delete entry.cli;
|
|
1919
|
+
delete entry.status;
|
|
1920
|
+
delete entry.failure_kind;
|
|
1921
|
+
}
|
|
1767
1922
|
if (Object.keys(omitted).length) entry.omitted = omitted;
|
|
1768
1923
|
if (truncated) {
|
|
1769
1924
|
entry.truncated = true;
|
|
@@ -1802,12 +1957,17 @@ export function pointsOf(view, ids) {
|
|
|
1802
1957
|
const known = recordableIds(r);
|
|
1803
1958
|
const unknown = ids.filter((id) => !known.includes(id));
|
|
1804
1959
|
if (unknown.length) {
|
|
1805
|
-
throw new RequestError(
|
|
1960
|
+
throw new RequestError(
|
|
1961
|
+
`${unknown.map((id) => `"${id}"`).join(', ')} not in this answer; it has: ${known.join(', ')}`,
|
|
1962
|
+
'unknown_entry_id',
|
|
1963
|
+
);
|
|
1806
1964
|
}
|
|
1807
1965
|
const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
|
|
1808
1966
|
const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
|
|
1809
1967
|
const points = [...new Set(ids)].map((id) => {
|
|
1810
|
-
const [kind, item] = Object.entries(lists)
|
|
1968
|
+
const [kind, item] = Object.entries(lists)
|
|
1969
|
+
.map(([k, l]) => [k, l.find((x) => x.id === id)])
|
|
1970
|
+
.find(([, x]) => x);
|
|
1811
1971
|
return { kind, ...item, recorded: verdicts.get(id) ?? null };
|
|
1812
1972
|
});
|
|
1813
1973
|
return {
|
|
@@ -1886,18 +2046,26 @@ function nextStep(job) {
|
|
|
1886
2046
|
// point showed, not when the answer arrives. Name the open ones until then.
|
|
1887
2047
|
const written = new Set((job.record?.entries ?? []).map((e) => e.id));
|
|
1888
2048
|
const open = job.result ? recordableIds(job.result).filter((id) => !written.has(id)).length : 0;
|
|
1889
|
-
const close =
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2049
|
+
const close =
|
|
2050
|
+
open > 0
|
|
2051
|
+
? ` Then write what checking showed: consult_record({ job_id: "${job.job_id}", entries: [{ id, verdict, effect }] }) -- ${open} point(s) still carry no verdict.`
|
|
2052
|
+
: '';
|
|
2053
|
+
return (
|
|
2054
|
+
(left > 0
|
|
2055
|
+
? `check the grounds behind the points that matter, run the one check that would change the decision, then either decide, or spend one of your ${left} remaining round(s) on the specific divergences (followup_to: "${job.job_id}").`
|
|
2056
|
+
: 'rounds exhausted: decide with what you have.') + close
|
|
2057
|
+
);
|
|
1895
2058
|
}
|
|
1896
|
-
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
1897
|
-
|
|
1898
|
-
if (job.failure?.kind === '
|
|
1899
|
-
|
|
1900
|
-
if (job.failure?.kind === '
|
|
2059
|
+
if (job.failure?.kind === 'timeout' || job.failure?.kind === 'cli_error')
|
|
2060
|
+
return 'retriable: narrow the brief and start a new consultation';
|
|
2061
|
+
if (job.failure?.kind === 'interrupted')
|
|
2062
|
+
return 'the severally process running it exited before the consultant answered; start the consultation again if you still need it';
|
|
2063
|
+
if (job.failure?.kind === 'rate_limited')
|
|
2064
|
+
return 'the consultant was rate-limited and the server already re-ran it (see retries); try again in a few minutes, or proceed on your own judgement';
|
|
2065
|
+
if (job.failure?.kind === 'usage_limit' || job.failure?.kind === 'auth')
|
|
2066
|
+
return 'not a consultation outcome: the consultant never answered. Proceed on your own judgement, or fix the credentials/quota first';
|
|
2067
|
+
if (job.failure?.kind === 'invalid_output')
|
|
2068
|
+
return 'the consultant answered but not in the required shape; retry once with a shorter brief before giving up';
|
|
1901
2069
|
return 'proceed on your own judgement and note that no peer input was obtained';
|
|
1902
2070
|
}
|
|
1903
2071
|
|