amicus 4.9.3 → 4.9.5
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +8 -5
- package/docs/architecture-map.md +736 -0
- package/docs/configuration.md +165 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +113 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/scripts/postinstall.js +4 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/sidecar/unzip.js +40 -0
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
package/skills/sidecar/SKILL.md
CHANGED
|
@@ -236,14 +236,7 @@ amicus start \
|
|
|
236
236
|
- `--context-turns <N>`: Max conversation turns to include (default: 50)
|
|
237
237
|
- `--context-since <duration>`: Time filter for context (e.g., `2h`, `30m`, `1d`). Overrides `--context-turns`.
|
|
238
238
|
- `--context-max-tokens <N>`: Max context size (default: 80000)
|
|
239
|
-
- `--thinking <level>`:
|
|
240
|
-
- `none` - No extended thinking
|
|
241
|
-
- `minimal` - Minimal thinking (may be adjusted if unsupported by model)
|
|
242
|
-
- `low` - Low thinking effort
|
|
243
|
-
- `medium` - Medium thinking effort (default)
|
|
244
|
-
- `high` - High thinking effort
|
|
245
|
-
- `xhigh` - Extra high thinking effort
|
|
246
|
-
Note: If the model doesn't support the specified level, it will be automatically adjusted.
|
|
239
|
+
- `--thinking <level>`: Reasoning effort — `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (the levels the curated routes declare between them). Omit it for the provider's own default effort (nothing is sent then). A level the model does not declare is refused before anything is sent (`VARIANT_UNDECLARED`, naming the declared set); a declared level whose thinking budget would push the reservation over `outputBudget` on the direct Anthropic route is refused too (`VARIANT_OVER_BUDGET`); a model the engine's catalogue does not know in time is sent the level unverified (`variantUnverified: true` on the record). Nothing is adjusted.
|
|
247
240
|
- `--summary-length <length>`: Summary verbosity:
|
|
248
241
|
- `brief` - Concise summary
|
|
249
242
|
- `normal` - Standard summary (default)
|
|
@@ -14,6 +14,7 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
|
|
|
14
14
|
const localProvidersCheck = require('./utils/doctor-local-providers-check');
|
|
15
15
|
// v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
|
|
16
16
|
const baseUrlCheck = require('./utils/doctor-base-url-check');
|
|
17
|
+
const outputBudgetCheck = require('./utils/doctor-output-budget-check'); // #218 PR 2 — the 'output-budget' row.
|
|
17
18
|
// B3 (council review of PR 198, issue 195) — the 'aliases' check body,
|
|
18
19
|
// including its --fix repair of fabricated bare ids. Same split rationale.
|
|
19
20
|
const aliasCheck = require('./utils/doctor-alias-check');
|
|
@@ -45,6 +46,7 @@ function realDeps() {
|
|
|
45
46
|
resolveModel: () => require('./utils/config').resolveModel(),
|
|
46
47
|
readCache: () => require('./utils/model-catalog').readCache(),
|
|
47
48
|
collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
|
|
49
|
+
readOutputBudgetRaw: () => (require('./utils/config').loadConfig() || {}).outputBudget, // #218 PR 2: as stored, so a malformed value is echoed
|
|
48
50
|
findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
|
|
49
51
|
findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
|
|
50
52
|
// B3: the narrow fabricated-bare-id repair class (pure detection) + the
|
|
@@ -171,6 +173,7 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
171
173
|
// utils/doctor-alias-check.js for the check body and utils/alias-audit.js's
|
|
172
174
|
// findFabricatedAliasRepairs for the detection rule.
|
|
173
175
|
checks.push(guard('aliases', 'Model aliases', () => aliasCheck.evaluateAliasesCheck(d)));
|
|
176
|
+
checks.push(guard('output-budget', 'Output budget', () => outputBudgetCheck.evaluateOutputBudget(d))); // #218 PR 2
|
|
174
177
|
|
|
175
178
|
checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
|
|
176
179
|
() => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const { validateTaskId, validateTag } = require('./utils/validators');
|
|
11
|
+
const { validateTaskId, validateTag, validateThinkingLevel } = require('./utils/validators');
|
|
12
12
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
13
13
|
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
14
14
|
const { applyTemplateForArgs } = require('./cli-template-args');
|
|
@@ -54,6 +54,15 @@ async function handleFanout(args) {
|
|
|
54
54
|
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` }));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// #218 PR 4 whole-branch review (VCMD-2): the same vocabulary check `start` runs in
|
|
58
|
+
// validateStartArgs (cli.js) — fanout never did, so a typo (or a pack's) reached sendPrompt:
|
|
59
|
+
// sent unverified to an unknown model, a per-leg VARIANT_UNDECLARED after the spawn on a
|
|
60
|
+
// known one. Named mutant "FANOUTTHINKINGUNCHECKED" (tests/fanout-cli.test.js): drop this block.
|
|
61
|
+
const thinkingCheck = validateThinkingLevel(args.thinking);
|
|
62
|
+
if (!thinkingCheck.valid) {
|
|
63
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: thinkingCheck.error }));
|
|
64
|
+
}
|
|
65
|
+
|
|
57
66
|
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
58
67
|
let promptRes;
|
|
59
68
|
if (args.prompt !== undefined || args['prompt-file'] !== undefined || args.template === undefined) {
|
|
@@ -32,6 +32,21 @@ async function handleResume(args) {
|
|
|
32
32
|
message: 'Error: --tag is not supported on resume — the tag is inherited from the parent session',
|
|
33
33
|
}));
|
|
34
34
|
}
|
|
35
|
+
// #218 PR 4, council #235 r1 (D6): the same silently-ignored-flag shape as --tag
|
|
36
|
+
// above. `--thinking` parses on every command because getKnownFlags()
|
|
37
|
+
// (utils/known-flags.js) scrapes the whole usage string and start's block declares
|
|
38
|
+
// it (cli.js), this handler never reads it, and validateStartArgs — PR 4's
|
|
39
|
+
// vocabulary check — runs only on `start` (cli-handlers-run.js), so even
|
|
40
|
+
// `--thinking bogus` used to exit 0 here having done nothing. Before PR 4 the level
|
|
41
|
+
// reached nothing on any path (probe F1) and the commands behaved alike by accident;
|
|
42
|
+
// it is the engine's `variant` on start/fanout now. Named mutant
|
|
43
|
+
// "RESUMETHINKINGSILENT" (tests/cli-handlers-resume-continue.test.js): drop this guard.
|
|
44
|
+
if (args.thinking !== undefined) {
|
|
45
|
+
process.exit(failJson(useJson, {
|
|
46
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
47
|
+
message: 'Error: --thinking is not supported on resume — the level belongs to the run that started the session, and resume reopens that session',
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
35
50
|
const taskId = requireValidTaskId(args, useJson, 'resume', 'Usage: amicus resume <task_id>');
|
|
36
51
|
requireNoUiForJson(args, useJson);
|
|
37
52
|
|
|
@@ -71,6 +86,16 @@ async function handleContinue(args) {
|
|
|
71
86
|
message: 'Error: --tag is not supported on continue — the tag is inherited from the parent session',
|
|
72
87
|
}));
|
|
73
88
|
}
|
|
89
|
+
// #218 PR 4, council #235 r1 (D6): same rationale as handleResume above — the flag
|
|
90
|
+
// parses here, is read by nobody, and is not even vocabulary-checked. A continuation
|
|
91
|
+
// is a NEW session, so a level for it belongs on the `start` that opens one. Named
|
|
92
|
+
// mutant "CONTINUETHINKINGSILENT" (tests/cli-handlers-resume-continue.test.js).
|
|
93
|
+
if (args.thinking !== undefined) {
|
|
94
|
+
process.exit(failJson(useJson, {
|
|
95
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
96
|
+
message: 'Error: --thinking is not supported on continue — the level belongs to the run that starts a session; use `amicus start --thinking <level>` to open one with a level',
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
74
99
|
const taskId = requireValidTaskId(args, useJson, 'continue', 'Usage: amicus continue <task_id> --prompt "..."');
|
|
75
100
|
|
|
76
101
|
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
package/src/cli.js
CHANGED
|
@@ -326,16 +326,13 @@ function validateStartArgs(args) {
|
|
|
326
326
|
return { valid: false, error: `Error: --summary-length must be one of: ${validSummaryLengths.join(', ')}` };
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
// Validate thinking
|
|
330
|
-
|
|
329
|
+
// Validate the thinking level's VOCABULARY (#218 PR 4). Whether the model
|
|
330
|
+
// declares it is checked against the engine's own catalogue at send time
|
|
331
|
+
// (opencode-client.js :: sendPrompt); nothing is adjusted here any more.
|
|
332
|
+
const thinkingCheck = validateThinkingLevel(args.thinking);
|
|
331
333
|
if (!thinkingCheck.valid) {
|
|
332
334
|
return thinkingCheck;
|
|
333
335
|
}
|
|
334
|
-
// If model doesn't support the level, adjust it and warn
|
|
335
|
-
if (thinkingCheck.warning) {
|
|
336
|
-
logger.warn('Thinking level adjusted', { warning: thinkingCheck.warning, adjustedLevel: thinkingCheck.adjustedLevel });
|
|
337
|
-
args.thinking = thinkingCheck.adjustedLevel;
|
|
338
|
-
}
|
|
339
336
|
|
|
340
337
|
// Validate API key is present for the model's provider
|
|
341
338
|
const apiKeyCheck = validateApiKey(args.model);
|
|
@@ -444,7 +441,7 @@ Options for 'start':
|
|
|
444
441
|
--context-since <duration> Time filter (e.g., 2h). Overrides turns.
|
|
445
442
|
--context-max-tokens <N> Max context tokens (default: 80000)
|
|
446
443
|
--summary-length <length> Summary verbosity: brief, normal (default), verbose
|
|
447
|
-
--thinking <level> Reasoning effort: none, minimal, low, medium, high, xhigh
|
|
444
|
+
--thinking <level> Reasoning effort: none, minimal, low, medium, high, xhigh, max (omit for the provider's default)
|
|
448
445
|
--mcp <spec> Add MCP server. Formats:
|
|
449
446
|
- name=url (remote server)
|
|
450
447
|
- name=command (local server)
|
|
@@ -148,7 +148,7 @@ function seatKeyedOrder(order, orderSeats) {
|
|
|
148
148
|
* changing it would move a shipped tally schema. These are three arrays each
|
|
149
149
|
* with one consistently-typed field, not one polymorphic field. PR #189's
|
|
150
150
|
* council raised it as D1 (major, CONTESTED a1/d1/n1); declined on that reading.
|
|
151
|
-
* @param {{reviews: Array<{model: string, text: string, seat?: ?object}>,
|
|
151
|
+
* @param {{reviews: Array<{model: string, text: string, seat?: ?object, cut?: boolean}>,
|
|
152
152
|
* rankings: Array<{judge: string, seat?: ?string, order: Array<string|string[]>,
|
|
153
153
|
* orderSeats?: ?Array<?string|Array<?string>>}>,
|
|
154
154
|
* adjudications: Array<{findingId: string, judge: string, seat?: ?string, verdict: string}>,
|
|
@@ -182,7 +182,9 @@ function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date,
|
|
|
182
182
|
// construction — and the review projection that feeds site (1) applies that
|
|
183
183
|
// same rule for a reason its own comment gives.
|
|
184
184
|
const reviewBlocks = reviews
|
|
185
|
-
|
|
185
|
+
// #218 PR 3 (council #232 r2 B1): a cut review says so in its header, so the
|
|
186
|
+
// chair weighs it as partial. Named mutant "NOMARKER".
|
|
187
|
+
.map(r => `--- Review by ${displayName(r.seat) || r.model}${r.cut ? ' — CUT at its output reservation (the provider stopped for length; the text ends where the reservation ended)' : ''} ---\n${r.text}`).join('\n\n');
|
|
186
188
|
const rankingLines = (rankings || [])
|
|
187
189
|
.map(r => `${r.seat || r.judge}: ${JSON.stringify(seatKeyedOrder(r.order, r.orderSeats))}`)
|
|
188
190
|
.join('\n');
|
|
@@ -254,14 +254,19 @@ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, recor
|
|
|
254
254
|
const packet = buildChairPacket({
|
|
255
255
|
// §4.4: the chair sees Claude's de-anonymized review like any other; it casts
|
|
256
256
|
// no rankings/adjudications, so it appears ONLY as one more review block.
|
|
257
|
-
// The projection is DELIBERATE — it drops findings/conformance/role/leg;
|
|
257
|
+
// The projection is DELIBERATE — it drops findings/conformance/role/leg;
|
|
258
|
+
// `cut` (r2 B1) is the one leg fact forwarded, emit-when-cut. v4.8
|
|
258
259
|
// SI-25 adds `seat`, ⚠️ EMIT-WHEN-DIFFERENT like rankings/adjudications above:
|
|
259
260
|
// `r.model` is the leg's `modelInput || model`, which falls back to the RESOLVED
|
|
260
261
|
// id, so an unconditional forward breaks §4.2 byte identity on a NO-TWIN bench.
|
|
261
262
|
// Mutant: tests/council/chair-packet-seat-mutants.js :: HDRSEATFWD.
|
|
262
263
|
// ⚠️ The Claude review keeps NO seat and renders `claude` via the fallback.
|
|
264
|
+
// #218 PR 3 (council #232 r2 B1): a review the provider cut at the output
|
|
265
|
+
// reservation is MARKED for the chair, emit-when-cut so an uncut bench's
|
|
266
|
+
// packet stays byte-identical. Named mutant "CUTDROPPED".
|
|
263
267
|
reviews: reviews.map(r => ({ model: r.model, text: r.text,
|
|
264
|
-
...(r.seat && r.seat.id !== r.seat.alias ? { seat: r.seat } : {})
|
|
268
|
+
...(r.seat && r.seat.id !== r.seat.alias ? { seat: r.seat } : {}),
|
|
269
|
+
...(r.leg && r.leg.finish === 'length' ? { cut: true } : {}) }))
|
|
265
270
|
.concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
|
|
266
271
|
rankings: tallyInput.rankings,
|
|
267
272
|
adjudications: tallyInput.adjudications,
|
|
@@ -179,5 +179,25 @@ function missingLegStillDeadNote(seat, ff, unit, counts) {
|
|
|
179
179
|
data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* #218 PR 3: a review that reached the packet but was cut at the reservation.
|
|
184
|
+
* `kind: 'info'` -- announced, never a loss (utils/degrade.js on the channel).
|
|
185
|
+
* The counts are the engine's own token record for the leg; the remedy names
|
|
186
|
+
* the one lever that exists today.
|
|
187
|
+
* @param {string} seat the alias every note renders — `materializeReviews` has
|
|
188
|
+
* already resolved it (`leg.modelInput || leg.model`), so the caller passes
|
|
189
|
+
* `m.modelInput` as-is
|
|
190
|
+
* @param {object} leg the leg run document (finish === 'length')
|
|
191
|
+
*/
|
|
192
|
+
function truncatedReviewNote(seat, leg) {
|
|
193
|
+
const t = (leg.usage && leg.usage.tokens) || {};
|
|
194
|
+
return { kind: 'info', channel: 'output-truncated',
|
|
195
|
+
what: `seat ${seat}'s review was cut at its output reservation`,
|
|
196
|
+
why: `the provider stopped for length (finish 'length') after ${t.reasoning || 0} reasoning / ${t.output || 0} output tokens; the review ends where the reservation ended`,
|
|
197
|
+
effect: 'The review is in the packet as far as it got, and its header in the chair packet says it was cut; nothing else changes',
|
|
198
|
+
remedy: 'raise outputBudget in config.json (docs/configuration.md, Output budget)',
|
|
199
|
+
data: { seat, finish: 'length', reasoningTokens: t.reasoning || 0, outputTokens: t.output || 0 } };
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
module.exports = { waveStillDeadNote, skippedWaveNote, srcLegStillDeadNote,
|
|
183
|
-
retryLegStillDeadNote, missingLegStillDeadNote };
|
|
203
|
+
retryLegStillDeadNote, missingLegStillDeadNote, truncatedReviewNote };
|
|
@@ -27,7 +27,7 @@ const { launchStage1 } = require('./run-stage1-launch');
|
|
|
27
27
|
const { buildRunStatsEntry } = require('./run-assemble');
|
|
28
28
|
const { pushDeadSeatRows } = require('./run-stage1-rows');
|
|
29
29
|
const { bindStage1Waves, orphanLegNote, missingSeatDeadWave } = require('./stage1-bind');
|
|
30
|
-
const { skippedWaveNote } = require('./run-retry-notes');
|
|
30
|
+
const { skippedWaveNote, truncatedReviewNote } = require('./run-retry-notes');
|
|
31
31
|
// slug lives in ./seats (v4.8 PR1) so that module can stay require-free;
|
|
32
32
|
// re-exported below — run-stages.test.js imports it from here.
|
|
33
33
|
const { slug } = require('./seats');
|
|
@@ -139,6 +139,13 @@ async function runStage1(ctx) {
|
|
|
139
139
|
// it is the twin clobber this PR removes (two healed twins, one file).
|
|
140
140
|
const allSeatOf = new Map([...seatOf, ...retry.seatOf]);
|
|
141
141
|
const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs], allSeatOf);
|
|
142
|
+
// #218 PR 3: a review the provider cut at the reservation still counts -- it
|
|
143
|
+
// is announced, not lost. Only MATERIALIZED legs qualify (a length-stopped
|
|
144
|
+
// leg with no answer text is a dead leg and never reaches this list); named
|
|
145
|
+
// mutants "DEADNOTED" (iterate `legs` instead) and "NONOTE" (drop the loop).
|
|
146
|
+
for (const m of materialized) {
|
|
147
|
+
if (m.leg && m.leg.finish === 'length') { ctx.degrade.note(truncatedReviewNote(m.modelInput, m.leg)); }
|
|
148
|
+
}
|
|
142
149
|
const stillDeadLegs = [...retry.skippedDeadLegs, ...retry.stillDeadLegs];
|
|
143
150
|
const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
|
|
144
151
|
|
package/src/headless.js
CHANGED
|
@@ -259,6 +259,22 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
|
|
|
259
259
|
return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* #218 PR 3: the budget for the OUTPUT_LENGTH reason string. The engine's own
|
|
264
|
+
* handle carries the value it was spawned with (opencode-client.js ::
|
|
265
|
+
* startServer, council #232 r1 B3) and wins; config is read only for a handle
|
|
266
|
+
* from outside amicus (a test seam or an older caller), and `undefined` when
|
|
267
|
+
* that read fails -- the string then says so rather than claiming "unset".
|
|
268
|
+
* `read` is a test seam (options._readOutputBudget).
|
|
269
|
+
* @param {{outputBudget?: number|null}} [server] the leg's server handle
|
|
270
|
+
* @param {() => (number|null)} [read]
|
|
271
|
+
* @returns {number|null|undefined}
|
|
272
|
+
*/
|
|
273
|
+
function readOutputBudgetSafe(server, read) {
|
|
274
|
+
if (server && Object.prototype.hasOwnProperty.call(server, 'outputBudget')) { return server.outputBudget; }
|
|
275
|
+
try { return (read || require('./utils/config').getOutputBudget)(); } catch { return undefined; }
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
/**
|
|
263
279
|
* v4.9 W10 (#133 piece 2): the engine-log lookup, wrapped so it can never
|
|
264
280
|
* become the failure it reports on. The resolver is already best-effort
|
|
@@ -349,8 +365,11 @@ async function waitForServer(client, checkHealthFn, maxAttempts = 30) {
|
|
|
349
365
|
* @param {object} [options] - Additional options
|
|
350
366
|
* @param {object} [options.mcp] - MCP server configurations
|
|
351
367
|
* @param {string} [options.summaryLength='normal'] - Desired summary length
|
|
352
|
-
* @param {
|
|
353
|
-
*
|
|
368
|
+
* @param {string} [options.variant] - #218 PR 4: the effort level, sent as the engine's `variant`
|
|
369
|
+
* prompt field and validated by sendPrompt against the model's declaration; a refusal is thrown
|
|
370
|
+
* before any request and becomes this leg's standard error result through the outer exception
|
|
371
|
+
* handler below (zero spend, nothing polled). The result carries `variant` (emit-when-sent) and
|
|
372
|
+
* `variantUnverified: true` when the engine's catalogue did not know the model in time.
|
|
354
373
|
* @param {string} [options.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). The
|
|
355
374
|
* PROMPT the caller built (prompt-builder.js buildPrompts) must have instructed the
|
|
356
375
|
* model with this SAME nonce — runHeadless only DETECTS, it never re-derives one from
|
|
@@ -372,7 +391,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
372
391
|
getSessionStatus
|
|
373
392
|
} = require('./opencode-client');
|
|
374
393
|
|
|
375
|
-
const {
|
|
394
|
+
const { variant } = options;
|
|
376
395
|
// 15b.3: never fall back to bare-marker detection — an omitted nonce still
|
|
377
396
|
// gets ONE generated here so findTrailingFoldMarker always has something to
|
|
378
397
|
// match, but since the prompt (built by the caller) never advertised THIS
|
|
@@ -635,9 +654,16 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
635
654
|
const agentConfig = mapAgentToOpenCode(agent || 'build');
|
|
636
655
|
promptOptions.agent = agentConfig.agent;
|
|
637
656
|
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
657
|
+
// #218 PR 4: the effort level goes out as the engine's `variant` prompt field
|
|
658
|
+
// (probe F2), validated in sendPrompt against what the model declares; the
|
|
659
|
+
// budget the engine was spawned with rides the handle (readOutputBudgetSafe,
|
|
660
|
+
// PR 3) so the direct-Anthropic fit check (M2/M17) judges the same number the
|
|
661
|
+
// death report names. Named mutant "VARIANTNOTSENT" (tests/headless-variant.test.js).
|
|
662
|
+
const sendAbort = new AbortController();
|
|
663
|
+
if (variant) {
|
|
664
|
+
promptOptions.variant = variant;
|
|
665
|
+
promptOptions.outputBudget = readOutputBudgetSafe(server, options._readOutputBudget);
|
|
666
|
+
promptOptions.signal = sendAbort.signal; // #218 PR 4 whole-branch review (EP-2): see the backstop catch below
|
|
641
667
|
}
|
|
642
668
|
|
|
643
669
|
// v4.6.2 PR2 amendment (controller live smoke, field evidence): arm BEFORE
|
|
@@ -760,6 +786,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
760
786
|
// internally, so this is defensive belt-and-suspenders, not load-bearing
|
|
761
787
|
// — verified empirically before relying on it).
|
|
762
788
|
sendPromptPromise.catch(() => {});
|
|
789
|
+
// #218 PR 4 whole-branch review (EP-2): the send may still be INSIDE its declaration
|
|
790
|
+
// wait (sendPrompt reads /config/providers for up to 5 s when a variant was asked for);
|
|
791
|
+
// this leg is finalized and its session aborted below, so the orphan must not send.
|
|
792
|
+
// Named mutant "ORPHANSENDS" (tests/headless-variant.test.js): drop the abort.
|
|
793
|
+
sendAbort.abort();
|
|
763
794
|
backstopFired = true;
|
|
764
795
|
logger.warn('No-output backstop fired before the prompt send resolved', {
|
|
765
796
|
taskId, sessionId, backstopMs: noOutputBackstopMs,
|
|
@@ -780,6 +811,27 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
780
811
|
sessionError = await noOutputBackstopReason();
|
|
781
812
|
}
|
|
782
813
|
|
|
814
|
+
// #218 PR 4: what was SENT, for the leg record (emit-when-sent; named mutants
|
|
815
|
+
// "VARIANTDROPPED" / "UNVERIFIEDHIDDEN" in tests/headless-variant.test.js). A refused
|
|
816
|
+
// variant never reaches here: sendPrompt throws before the request and the outer
|
|
817
|
+
// exception handler returns the standard error result.
|
|
818
|
+
const sent = promptResult && promptResult.sentVariant;
|
|
819
|
+
const sentVariantFields = sent
|
|
820
|
+
? { variant: sent.variant, ...(sent.verified ? {} : { variantUnverified: true }) }
|
|
821
|
+
: {};
|
|
822
|
+
if (sent && !sent.verified) {
|
|
823
|
+
const { formatUnverifiedVariantNote } = require('./utils/engine-variants');
|
|
824
|
+
const note = formatUnverifiedVariantNote({ model, variant: sent.variant, waitedMs: sent.waitedMs, unreadable: sent.unreadable });
|
|
825
|
+
logger.warn('Variant sent unverified', { taskId, sessionId, note });
|
|
826
|
+
// council #235 r2 (B2): logger.warn is DROPPED at the shipped default
|
|
827
|
+
// (LOG_LEVEL defaults to 'error', utils/logger.js), so the structured line alone
|
|
828
|
+
// told the user nothing — the silent degrade the product principle forbids, and the
|
|
829
|
+
// same invisibility this release cites against 4.9.3's silent adjustment. stderr
|
|
830
|
+
// carries it in every mode; stdout keeps the run document intact. Named mutant
|
|
831
|
+
// "UNVERIFIEDNOTICESILENT": drop the stderr write.
|
|
832
|
+
process.stderr.write(`Notice: ${note}\n`);
|
|
833
|
+
}
|
|
834
|
+
|
|
783
835
|
// Hard provider failure detected at the client boundary (#37): a non-2xx /
|
|
784
836
|
// 402 from promptAsync surfaces here even when the server never emits an
|
|
785
837
|
// assistant message carrying info.error. Seed sessionError so the loop's
|
|
@@ -996,6 +1048,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
996
1048
|
// seven first, and nothing observes them afterwards.
|
|
997
1049
|
// Hoisting the whole block (rather than adding a second stamp beside each
|
|
998
1050
|
// gate) is what keeps ONE definition of the predicate and ONE stamp site.
|
|
1051
|
+
// #218 PR 3: the mirror's promotion reset (conversation-mirror.js ::
|
|
1052
|
+
// mirrorMessages) can SHRINK output once -- the poll where real answer
|
|
1053
|
+
// text replaces a longer promoted stand-in reads no growth here. Bounded
|
|
1054
|
+
// to that poll: TTFT and the backstop were already latched by the
|
|
1055
|
+
// reasoning growth, and the next growth compares against the new length.
|
|
999
1056
|
const outputGrew = mirror.output.length > lastOutputLength;
|
|
1000
1057
|
lastOutputLength = mirror.output.length;
|
|
1001
1058
|
const toolActivity = mirror.toolCalls.length > lastToolCallCount;
|
|
@@ -1120,6 +1177,30 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1120
1177
|
break;
|
|
1121
1178
|
}
|
|
1122
1179
|
|
|
1180
|
+
// #218 PR 3: the engine finalized the message with finish 'length' and
|
|
1181
|
+
// no answer text -- the Mode 2 death (probe row L1: hidden reasoning,
|
|
1182
|
+
// no content part); decided on THAT message's parts, not on the
|
|
1183
|
+
// session's accumulated output (council #232 r1 B2/D1).
|
|
1184
|
+
// Nothing more will arrive from that message. With EMPTY output (hidden
|
|
1185
|
+
// reasoning, L1) every exit below requires output, so without this one
|
|
1186
|
+
// the leg waits out the no-output backstop and dies under ITS name,
|
|
1187
|
+
// which says "silence past the deadline" about a message the engine had
|
|
1188
|
+
// already finished with a reason. With output -- reasoning the mirror
|
|
1189
|
+
// promoted (L2/L4; the backstop is already disarmed by that growth) or
|
|
1190
|
+
// an earlier message's text -- the idle exits would end it instead: in
|
|
1191
|
+
// this same poll on SDK idle, up to stableFinishedPolls later on the
|
|
1192
|
+
// heuristic. This exit is the same death, no later. Gated on 'length'
|
|
1193
|
+
// only: the finalized message's finish is never a step-level
|
|
1194
|
+
// 'tool-calls' (B4's measured evidence below: time.completed lands after
|
|
1195
|
+
// the tool ends), and a 'stop' with no text is a different, unnamed
|
|
1196
|
+
// death. The message flag here matches the post-loop decision, which is
|
|
1197
|
+
// the pinned one (named mutants NOEXIT and SESSIONWIDE in
|
|
1198
|
+
// tests/headless-output-length.test.js).
|
|
1199
|
+
if (assistantFinished && mirror.lastAssistantFinish === 'length' && !mirror.lastAssistantHasText) {
|
|
1200
|
+
logger.error('Assistant message finished for length with no answer text, exiting', { taskId, pollCount });
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1123
1204
|
// Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
|
|
1124
1205
|
// Gate on real output so a pre-processing 'idle' cannot end the run early.
|
|
1125
1206
|
// Best-effort: on any error, fall back to the activity heuristic below.
|
|
@@ -1478,6 +1559,33 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1478
1559
|
const { sumPerMessageUsage } = require('./utils/pricing');
|
|
1479
1560
|
const usage = sumPerMessageUsage(mirror.usageByMsg);
|
|
1480
1561
|
|
|
1562
|
+
// #218 PR 3: name the Mode 2 death. The engine records `finish: 'length'`
|
|
1563
|
+
// when the provider stopped at the max_tokens reservation (probe rows
|
|
1564
|
+
// A/H1/L1-L4); with no answer text that is a dead leg, and it used to leave
|
|
1565
|
+
// here as `completed` with an empty summary -- or with its THINKING promoted
|
|
1566
|
+
// to the summary (L2/L4's shape). Named through the channel every other
|
|
1567
|
+
// death uses (sessionError -> leg.error -> metadata.reason -> the dead-leg
|
|
1568
|
+
// note), so no consumer needs a new case. An error the engine itself put on
|
|
1569
|
+
// the message wins: its own name is the better observation. Named mutants
|
|
1570
|
+
// (tests/headless-output-length.test.js): "ENGINEERRORLOST" drops the
|
|
1571
|
+
// `!sessionError` guard; "DEATHNOTFORCED" drops `|| outputLengthDeath` from
|
|
1572
|
+
// failedWithNoUsableOutput below.
|
|
1573
|
+
//
|
|
1574
|
+
// Decided on the LAST message's own facts (council #232 r1 B2/D1): a tool
|
|
1575
|
+
// loop's earlier text or promoted reasoning is not this message's answer.
|
|
1576
|
+
// The ambient flag rides the handle beside the budget (council #232 r3 B1); named mutant "AMBIENTNOTREAD" drops it here.
|
|
1577
|
+
const { isOutputLengthDeath, formatOutputLengthReason } = require('./utils/output-length');
|
|
1578
|
+
const finish = mirror.lastAssistantFinish;
|
|
1579
|
+
const reasoningOnly = mirror.lastAssistantHasReasoning && !mirror.lastAssistantHasText;
|
|
1580
|
+
const outputLengthDeath = isOutputLengthDeath({ finish, hasText: mirror.lastAssistantHasText });
|
|
1581
|
+
if (outputLengthDeath && !sessionError) {
|
|
1582
|
+
sessionError = formatOutputLengthReason({
|
|
1583
|
+
tokens: usage.tokens, budget: readOutputBudgetSafe(server, options._readOutputBudget), reasoningOnly,
|
|
1584
|
+
ambientFlag: server && typeof server.ambientOutputTokenFlag === 'string' ? server.ambientOutputTokenFlag : null,
|
|
1585
|
+
});
|
|
1586
|
+
logger.error('Leg stopped for length with no answer text', { taskId, error: sessionError });
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1481
1589
|
// ---- v4.4 B3: one TERMINAL progress record carrying the settled usage ----
|
|
1482
1590
|
// progress.json's `usage` block was previously stamped only on 'receiving'
|
|
1483
1591
|
// flushes, which fire on text/tool/reasoning GROWTH — always strictly before
|
|
@@ -1550,7 +1658,10 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1550
1658
|
//
|
|
1551
1659
|
// `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
|
|
1552
1660
|
// and the returned shape are decided by ONE predicate and cannot drift.
|
|
1553
|
-
|
|
1661
|
+
// #218 PR 3: an OUTPUT_LENGTH death can have a non-empty mirror.output -- a
|
|
1662
|
+
// tool loop's earlier message text, or reasoning promoted before the answer
|
|
1663
|
+
// was known -- and must still fail.
|
|
1664
|
+
const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath));
|
|
1554
1665
|
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
1555
1666
|
const terminalStage = resolveTerminalState({
|
|
1556
1667
|
completed,
|
|
@@ -1578,6 +1689,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1578
1689
|
...settleResult,
|
|
1579
1690
|
...subtreeFlags,
|
|
1580
1691
|
...subtreeResult,
|
|
1692
|
+
...sentVariantFields,
|
|
1581
1693
|
// #133 P1: sessionId was assigned at :413/:417, well before this
|
|
1582
1694
|
// return — guaranteed set here, same as `taskId` above.
|
|
1583
1695
|
opencodeSessionId: sessionId,
|
|
@@ -1587,6 +1699,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1587
1699
|
// and must keep it. (PR #207 round 3, B3: emit-when-VALID too — see the
|
|
1588
1700
|
// clock-skew ruling at the stamp site above.)
|
|
1589
1701
|
...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
|
|
1702
|
+
// #218 PR 3: the engine's finish for the last assistant message, emit-when-set like ttftMs.
|
|
1703
|
+
...(typeof finish === 'string' ? { finish } : {}),
|
|
1590
1704
|
error: sessionError
|
|
1591
1705
|
};
|
|
1592
1706
|
}
|
|
@@ -1602,10 +1716,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1602
1716
|
...settleResult,
|
|
1603
1717
|
...subtreeFlags,
|
|
1604
1718
|
...subtreeResult,
|
|
1719
|
+
...sentVariantFields,
|
|
1605
1720
|
// #133 P1: see the comment on the sibling return above — guaranteed set.
|
|
1606
1721
|
opencodeSessionId: sessionId,
|
|
1607
1722
|
// v4.9 W13 Task A: emit-when-set — see the sibling return above.
|
|
1608
1723
|
...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
|
|
1724
|
+
// #218 PR 3: the engine's finish for the last assistant message, emit-when-set like ttftMs.
|
|
1725
|
+
...(typeof finish === 'string' ? { finish } : {}),
|
|
1609
1726
|
exitCode: 0
|
|
1610
1727
|
};
|
|
1611
1728
|
|
|
@@ -1778,6 +1895,7 @@ module.exports = {
|
|
|
1778
1895
|
findTrailingFoldMarker,
|
|
1779
1896
|
formatFoldOutput,
|
|
1780
1897
|
formatNoOutputBackstopReason,
|
|
1898
|
+
readOutputBudgetSafe,
|
|
1781
1899
|
DEFAULT_TIMEOUT,
|
|
1782
1900
|
FOLD_MARKER,
|
|
1783
1901
|
COMPLETE_MARKER,
|
package/src/mcp-server.js
CHANGED
|
@@ -332,6 +332,20 @@ const handlers = {
|
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// #218 PR 4 whole-branch review (VCMD-1): the zod enum closes the TYPED door only — a pack
|
|
336
|
+
// fills `thinking` onto `input` after zod ran (pack-resolve.js), and the in-process
|
|
337
|
+
// shared-server branch below never runs validateStartArgs. The same vocabulary check the
|
|
338
|
+
// CLI runs (cli.js), here for both paths. Named mutant "PACKTHINKINGUNCHECKED"
|
|
339
|
+
// (tests/pack/mcp-pack-params.test.js): drop this block.
|
|
340
|
+
{
|
|
341
|
+
const { validateThinkingLevel } = require('./utils/thinking-validators');
|
|
342
|
+
const thinkingCheck = validateThinkingLevel(input.thinking);
|
|
343
|
+
if (!thinkingCheck.valid) {
|
|
344
|
+
const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
|
|
345
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({ code: ERROR_CODES.BAD_ARGS, message: thinkingCheck.error, hint: null })) }] };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
335
349
|
// Model routing (#61 Task 6.2): route through the gateway router for MCP
|
|
336
350
|
// parity with the CLI's resolveLaunchModel (start-helpers.js). Unlike the
|
|
337
351
|
// CLI, this handler must never process.exit — the MCP server is long-lived
|
|
@@ -527,6 +541,7 @@ const handlers = {
|
|
|
527
541
|
// so without them status/list/read show a briefing-less, mode-less run.
|
|
528
542
|
mode: 'headless',
|
|
529
543
|
agent: agent || 'build',
|
|
544
|
+
...(input.thinking ? { thinking: input.thinking } : {}), // #218 PR 4 whole-branch review (REC-1): emit-when-requested, as createSessionMetadata does (start-metadata.js)
|
|
530
545
|
// v4.5 HOLD-gate decision 1: the RENDERED prompt (byte-identical to
|
|
531
546
|
// input.prompt when no pack template applied) — parity with the CLI,
|
|
532
547
|
// whose briefing.md on disk is always the rendered text (spec §4).
|
|
@@ -598,6 +613,11 @@ const handlers = {
|
|
|
598
613
|
// moment a consumer (e.g. metadata/fold-output) needs it (12a.1/B02).
|
|
599
614
|
amicusClient: detectedClient,
|
|
600
615
|
nonce: foldNonce,
|
|
616
|
+
// #218 PR 4 whole-branch review (REC-1/VCMD-1/PRT-1): the level goes to the engine
|
|
617
|
+
// on THIS path too — it was argv-only (:434), which this branch never reads, so
|
|
618
|
+
// `thinking` was dropped silently while the tool text promised a refusal.
|
|
619
|
+
// Named mutant "SHAREDPATHNOVARIANT" (tests/mcp-server-wait-wiring.test.js).
|
|
620
|
+
variant: input.thinking || undefined,
|
|
601
621
|
}
|
|
602
622
|
).then((result) => {
|
|
603
623
|
// Session done — route through resolveTerminalState (same single source
|
|
@@ -1305,6 +1325,12 @@ const handlers = {
|
|
|
1305
1325
|
&& (typeof input.timeout !== 'number' || !Number.isFinite(input.timeout) || input.timeout <= 0)) {
|
|
1306
1326
|
return textResult('Error: timeout must be a positive number of minutes.', true);
|
|
1307
1327
|
}
|
|
1328
|
+
// #218 PR 4 whole-branch review (VCMD-2): a pack-filled `thinking` bypasses the zod enum here too.
|
|
1329
|
+
{
|
|
1330
|
+
const { validateThinkingLevel } = require('./utils/thinking-validators');
|
|
1331
|
+
const thinkingCheck = validateThinkingLevel(input.thinking);
|
|
1332
|
+
if (!thinkingCheck.valid) { return textResult(thinkingCheck.error, true); }
|
|
1333
|
+
}
|
|
1308
1334
|
|
|
1309
1335
|
// Resolve a single effective models list (council OR models), validated
|
|
1310
1336
|
// BEFORE any wave dir / metadata is written so a bad request never strands
|
package/src/mcp-tools.js
CHANGED
|
@@ -80,9 +80,9 @@ function getTools() {
|
|
|
80
80
|
'Run headless without GUI. Default false (opens Electron window).'
|
|
81
81
|
),
|
|
82
82
|
thinking: z.enum([
|
|
83
|
-
'none', 'minimal', 'low', 'medium', 'high', 'xhigh'
|
|
83
|
+
'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
|
|
84
84
|
]).optional().describe(
|
|
85
|
-
'Reasoning effort level.
|
|
85
|
+
'Reasoning effort level. Omitted: nothing is sent and the provider\'s own default effort governs. A level the model does not declare is refused before anything is spent (the engine lists what each model declares). A model the engine\'s catalogue does not know in time is sent the level unverified (variantUnverified: true on the record).'
|
|
86
86
|
),
|
|
87
87
|
timeout: z.number().optional().describe(
|
|
88
88
|
'Headless timeout in minutes. Default: 15. Only applies when noUi is true.'
|
|
@@ -347,8 +347,8 @@ function getTools() {
|
|
|
347
347
|
agent: z.enum(['Plan', 'Build']).optional().describe(
|
|
348
348
|
'Agent mode for every leg. Build (default): full tool access. Plan: read-only analysis. Chat is not supported headless.'
|
|
349
349
|
),
|
|
350
|
-
thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional().describe(
|
|
351
|
-
'Reasoning effort for every leg.
|
|
350
|
+
thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional().describe(
|
|
351
|
+
'Reasoning effort for every leg. Omitted: nothing is sent and each provider\'s own default effort governs. A leg whose model does not declare the level is refused before anything is spent; the other legs run. A leg whose model the engine\'s catalogue does not know in time is sent the level unverified.'
|
|
352
352
|
),
|
|
353
353
|
timeout: z.number().positive('timeout must be a positive number of minutes').optional().describe(
|
|
354
354
|
'Per-leg timeout in minutes (wall-clock ≈ slowest leg). Default: 15.'
|