severally-mcp 2.4.2 → 2.5.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/CONFIG.md CHANGED
@@ -78,6 +78,7 @@ A name is lower-case letters, digits, `.` and `-`, at most 64 characters; `all`
78
78
  | `effort` | string | `high` | Reasoning effort, translated per CLI: Codex `-c model_reasoning_effort=…`, Claude Code `--effort …`, OpenCode the model variant `provider/model#…`. A value the CLI or model does not support fails the consultation as `model_unavailable` (e.g. glm-5.2 has `high`/`max` only). For OpenCode, a model written with its own `#variant` keeps it, and one written with a bare trailing `#` (`opencode/nemotron-3-ultra-free#`) runs with no variant, for models that have none. Not allowed on an Antigravity consultant: agy takes the effort from the model name (`gemini-3.8-flash-low` / `-medium` / `-high`). |
79
79
  | `timeout_ms` | number, 1000–7200000 | `server.timeout_ms` | This consultant's own wall-clock budget, for the one that reliably needs longer or shorter than the rest. |
80
80
  | `max_budget_usd` | number, 0.05–20 | `10` | Claude Code consultants only: the spending cap for one consultation. |
81
+ | `fallback_model` | string | – | A model on the same CLI to run instead when `model`'s account quota is used up (`usage_limit`). The consultation is run again once, inside the same time budget, and the answer says that the fallback gave it; `model` on the result is the fallback, and the switch is listed under `retries`. If the fallback fails too, that failure is reported. Must differ from `model`. |
81
82
  | `vendor` | string | from the model name | The lineage, for the same-lineage note on an answer. Read from the model name (`gpt` → openai, `claude` → anthropic, `gemini` → google, `glm` → zai, `deepseek` → deepseek, `nemotron` → nvidia); set it for a model the name does not place. |
82
83
  | `enabled` | boolean | `true` | Turns this one consultant off, leaving the others on its CLI. |
83
84
  | `note` | string | – | Why it is off, as for `clis`. |
package/README.ja.md CHANGED
@@ -90,7 +90,7 @@ flowchart TB
90
90
  macOS、Linux、Windows(PowerShell またはコマンドプロンプト)のどれでも、次の 1 行で入れられます。
91
91
 
92
92
  ```bash
93
- npx -y severally-mcp@2.4.2 install
93
+ npx -y severally-mcp@2.5.0 install
94
94
  ```
95
95
 
96
96
  インストーラは、`PATH` で見つかった CLI のそれぞれに severally を登録します。末尾に `--dry-run` を付けると、何も
package/README.md CHANGED
@@ -92,7 +92,7 @@ routes and the real CLIs have been tried on Linux only.
92
92
  One line, on macOS, Linux and Windows (PowerShell or Command Prompt):
93
93
 
94
94
  ```bash
95
- npx -y severally-mcp@2.4.2 install
95
+ npx -y severally-mcp@2.5.0 install
96
96
  ```
97
97
 
98
98
  The installer registers severally with each CLI it finds on `PATH`. Add `--dry-run` at the end to print what it
@@ -17,7 +17,7 @@
17
17
  "opus": { "cli": "claude-code", "model": "claude-opus-5-5", "effort": "max", "max_budget_usd": 5, "timeout_ms": 900000 },
18
18
  "fable": { "cli": "claude-code", "model": "claude-fable-5-1" },
19
19
  "sol": { "cli": "codex", "model": "gpt-6.1-sol" },
20
- "gemini": { "cli": "antigravity", "model": "gemini-4-argon-high" },
20
+ "gemini": { "cli": "antigravity", "model": "gemini-4-argon-high", "fallback_model": "gemini-3.8-flash-high" },
21
21
  "glm": { "cli": "opencode", "model": "zai-coding-plan/glm-5.3" },
22
22
  "deepseek": { "cli": "opencode", "model": "opencode/deepseek-v4.1-flash-free", "enabled": false, "note": "trial ended" },
23
23
  "nemotron": { "cli": "opencode", "model": "opencode/nemotron-3-ultra-free#", "vendor": "nvidia" }
@@ -37,6 +37,8 @@ OpenCode ──(skill)──> severally server ──> codex exec | claude -p
37
37
  - 相談を始めても、主担当は相談している計画そのものは進めません。
38
38
  - 一時的な失敗(`rate_limited`、`cli_error`)は、少し待ってから、同じ時間の枠の中で、同じ相談相手とモデルで最大 2 回
39
39
  やり直します。それまでの失敗は `retries` に残ります。
40
+ - 相談相手のモデルがアカウントの使用枠を使い切ったとき(`usage_limit`)は、設定に `fallback_model` があれば、その
41
+ モデルで 1 回だけやり直します。答えには代わりのモデルが答えたことが書かれ、`model` には実際に答えたモデルが入ります。
40
42
  - 時間切れになったときは、相談相手がそこまでに書いていた内容の末尾を、途中までのものという印を付けて失敗の隣に
41
43
  残します。これは読む材料として残すもので、答えとしては扱いません。
42
44
  - 相談の結果は、サーバを再起動したあとも、同じマシンで動くほかの severally のプロセスからも読めます。ほかのプロセスで
@@ -38,6 +38,8 @@ OpenCode ──(skill)──> severally server ──> codex exec | claude -p
38
38
  - While a consultation runs, the lead does not go ahead with the plan in question.
39
39
  - A transient failure (`rate_limited`, `cli_error`) is run again up to twice, after a short wait and inside the
40
40
  same time budget, with the same consultant and model; the earlier failures are listed under `retries`.
41
+ - A consultant whose model has used up its account quota (`usage_limit`) is run again once on its `fallback_model`,
42
+ if the config names one. The answer says that the fallback gave it, and `model` shows the one that answered.
41
43
  - A consultant that runs out of time leaves the end of what it had written beside the failure, marked partial:
42
44
  something to read, not an answer.
43
45
  - A consultation stays readable after the server restarts, and from any other severally process on the machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "severally-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "A plugin that lets your AI agent ask other coding agents for independent opinions, returned separately.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "severally",
4
- "version": "2.4.2",
4
+ "version": "2.5.0",
5
5
  "description": "Ask Codex, Claude Code, Antigravity or OpenCode for an independent opinion, review or structured debate, in a sandboxed child session that can read files and search the web but cannot edit files, change the system, or start other agents.",
6
6
  "author": {
7
7
  "name": "coz.a",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "severally",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Ask Codex, Claude Code, Antigravity or OpenCode for an independent opinion, review or structured debate, in a sandboxed child session that can read files and search the web but cannot edit files, change the system, or start other agents.",
5
5
  "author": {
6
6
  "name": "coz.a",
@@ -863,7 +863,7 @@ function buildConsultants(config2) {
863
863
  if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(", ")})`);
864
864
  if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(", ")}`);
865
865
  if (typeof def.model !== "string" || def.model.trim() === "") problems.push(`${at}.model: required`);
866
- for (const k of ["effort", "vendor", "note"]) {
866
+ for (const k of ["effort", "fallback_model", "vendor", "note"]) {
867
867
  if (def[k] !== void 0 && typeof def[k] !== "string") problems.push(`${at}.${k}: must be a string`);
868
868
  }
869
869
  if (def.enabled !== void 0 && typeof def.enabled !== "boolean") problems.push(`${at}.enabled: must be true or false`);
@@ -871,6 +871,10 @@ function buildConsultants(config2) {
871
871
  if (def[k] !== void 0 && !(typeof def[k] === "number" && Number.isFinite(def[k])))
872
872
  problems.push(`${at}.${k}: must be a number`);
873
873
  }
874
+ if (typeof def.fallback_model === "string" && def.fallback_model.trim() === "")
875
+ problems.push(`${at}.fallback_model: must not be empty`);
876
+ else if (typeof def.fallback_model === "string" && def.fallback_model.trim() === String(def.model).trim())
877
+ problems.push(`${at}.fallback_model: must differ from model; the fallback is for when the model's quota is used up`);
874
878
  if (def.max_budget_usd !== void 0 && def.cli !== "claude-code")
875
879
  problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
876
880
  if (def.effort !== void 0 && def.cli === "antigravity")
@@ -905,6 +909,7 @@ function make(name, def) {
905
909
  effort: typeof def.effort === "string" && def.effort.trim() !== "" ? def.effort.trim() : null,
906
910
  timeoutMs: typeof def.timeout_ms === "number" ? clamp(def.timeout_ms, 1e3, 72e5) : null,
907
911
  maxBudgetUsd: typeof def.max_budget_usd === "number" ? clamp(def.max_budget_usd, 0.05, 20) : null,
912
+ fallbackModel: typeof def.fallback_model === "string" && def.fallback_model.trim() !== "" ? def.fallback_model.trim() : null,
908
913
  vendor: vendorOf(model, def.vendor),
909
914
  enabled: def.enabled !== false,
910
915
  note: typeof def.note === "string" && def.note.trim() !== "" ? def.note.trim() : null
@@ -958,7 +963,7 @@ var init_consultants = __esm({
958
963
  };
959
964
  TOP_KEYS = ["server", "clis", "consultants", "ranking"];
960
965
  CLI_KEYS = ["enabled", "note", "bin", "args", "env", "provider"];
961
- CONSULTANT_KEYS = ["cli", "model", "effort", "timeout_ms", "max_budget_usd", "vendor", "enabled", "note"];
966
+ CONSULTANT_KEYS = ["cli", "model", "effort", "timeout_ms", "max_budget_usd", "fallback_model", "vendor", "enabled", "note"];
962
967
  isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
963
968
  clamp = (v, min, max) => Math.min(max, Math.max(min, v));
964
969
  }
@@ -39946,9 +39951,9 @@ function unreadCaveat(calls, readTools) {
39946
39951
  }
39947
39952
  function refusedToolCaveat(calls, allowed) {
39948
39953
  if (!calls || !allowed) return null;
39949
- const names = Object.keys(calls).filter((k) => k.endsWith(":error")).map((k) => k.slice(0, -":error".length)).filter((t) => !allowed.includes(t));
39954
+ const names = Object.keys(calls).filter((k) => k.endsWith(":error")).map((k) => k.slice(0, -":error".length)).filter((t) => !allowed.includes(t) && /read|view|open/i.test(t));
39950
39955
  if (!names.length) return null;
39951
- return `the consultant's CLI called ${names.join(", ")}, which severally does not allow, and was refused; a new CLI release may have renamed a tool`;
39956
+ return `the consultant's CLI called ${names.join(", ")}, which severally does not allow, and was refused; a new CLI release may have renamed its file-reading tool`;
39952
39957
  }
39953
39958
  function sameVendorCaveat(caller, callerModel, consultant) {
39954
39959
  const callerCli = CLI_IDS.find((c) => c === caller);
@@ -40951,20 +40956,26 @@ var init_jobs = __esm({
40951
40956
  this.#mark(job);
40952
40957
  const t0 = Date.now();
40953
40958
  if (adapter.prepare) await adapter.prepare();
40954
- const invocation = adapter.buildInvocation({
40955
- workdir,
40956
- schemaPath,
40957
- guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
40958
- sandbox,
40959
- consultant,
40960
- hasExposedPaths: Boolean(req._snapshot),
40961
- network: req.network
40962
- });
40963
- const argsProblem = extraArgsProblem(cli, invocation.args, t.extraArgs);
40964
- if (argsProblem) return this.#fail(job, "spawn_error", `config clis.${cli}.args: ${argsProblem}`);
40965
- invocation.args = withExtraArgs(invocation.args, t.extraArgs);
40966
- assertNoForbiddenFlags(cli, invocation.args);
40967
- job.boundary = adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ?? null;
40959
+ const launch = (c) => {
40960
+ const invocation2 = adapter.buildInvocation({
40961
+ workdir,
40962
+ schemaPath,
40963
+ guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
40964
+ sandbox,
40965
+ consultant: c,
40966
+ hasExposedPaths: Boolean(req._snapshot),
40967
+ network: req.network
40968
+ });
40969
+ const problem = extraArgsProblem(cli, invocation2.args, t.extraArgs);
40970
+ if (problem) return problem;
40971
+ invocation2.args = withExtraArgs(invocation2.args, t.extraArgs);
40972
+ assertNoForbiddenFlags(cli, invocation2.args);
40973
+ job.boundary = adapter.boundary?.({ args: invocation2.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ?? null;
40974
+ return invocation2;
40975
+ };
40976
+ const first = launch(consultant);
40977
+ if (typeof first === "string") return this.#fail(job, "spawn_error", `config clis.${cli}.args: ${first}`);
40978
+ let invocation = first;
40968
40979
  const attempt = (timeout) => {
40969
40980
  const { handle, done } = runChild({
40970
40981
  command: invocation.command,
@@ -40988,6 +40999,7 @@ var init_jobs = __esm({
40988
40999
  let interpreted;
40989
41000
  let authTried = false;
40990
41001
  let autoRetries = 0;
41002
+ let fallenBack = false;
40991
41003
  for (; ; ) {
40992
41004
  job.tool_calls = adapter.toolCalls?.(run) ?? null;
40993
41005
  job.duration_ms = Date.now() - t0;
@@ -41044,6 +41056,22 @@ var init_jobs = __esm({
41044
41056
  continue;
41045
41057
  }
41046
41058
  }
41059
+ if (interpreted.failureKind === "usage_limit" && consultant.fallbackModel && !fallenBack && remaining >= MIN_RETRY_MS) {
41060
+ fallenBack = true;
41061
+ const fallback = consultant.fallbackModel;
41062
+ const next = launch({ ...consultant, model: fallback });
41063
+ if (typeof next === "string") return this.#fail(job, "spawn_error", `config clis.${cli}.args: ${next}`);
41064
+ invocation = next;
41065
+ job.retries.push({
41066
+ failure_kind: interpreted.failureKind,
41067
+ reason: `${job.model} quota used up; re-run on ${fallback}`,
41068
+ at: (/* @__PURE__ */ new Date()).toISOString(),
41069
+ usage: adapter.usageRecord(interpreted.usageRaw)
41070
+ });
41071
+ job.model = fallback;
41072
+ run = await attempt(remaining);
41073
+ continue;
41074
+ }
41047
41075
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
41048
41076
  if (AUTO_RETRY_KINDS.includes(interpreted.failureKind) && autoRetries < POLICY.maxAutoRetries && remaining - wait >= MIN_RETRY_MS) {
41049
41077
  autoRetries += 1;
@@ -41101,6 +41129,7 @@ var init_jobs = __esm({
41101
41129
  sameVendorCaveat(job.caller, job.caller_model, consultant),
41102
41130
  interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
41103
41131
  leftover && "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)",
41132
+ fallenBack && `answered by ${job.model}: ${consultant.model}'s quota was used up`,
41104
41133
  req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
41105
41134
  refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS)
41106
41135
  ].filter(Boolean);
@@ -42112,7 +42141,7 @@ var init_server = __esm({
42112
42141
  init_schema();
42113
42142
  init_jobs();
42114
42143
  init_export();
42115
- SEVERALLY_VERSION = "2.4.2";
42144
+ SEVERALLY_VERSION = "2.5.0";
42116
42145
  DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : Math.min(POLICY.maxWaitMs, 45e3);
42117
42146
  PROGRESS_EVERY_MS = 15e3;
42118
42147
  semver = (v) => String(v).split(".").map(Number);
@@ -50,7 +50,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
50
50
 
51
51
  **Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
52
52
  consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
53
- for the one the user asked for.
53
+ for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
54
+ "answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
54
55
 
55
56
  Consult your own CLI (`target: "{{SELF_TARGET}}"`) freely, alone or alongside the peers, and the same goes
56
57
  for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
61
61
 
62
62
  **Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
63
63
  consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
64
- for the one the user asked for.
64
+ for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
65
+ "answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
65
66
 
66
67
  Consult your own CLI (`target: "antigravity"`) freely, alone or alongside the peers, and the same goes
67
68
  for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
@@ -62,7 +62,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
62
62
 
63
63
  **Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
64
64
  consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
65
- for the one the user asked for.
65
+ for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
66
+ "answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
66
67
 
67
68
  Consult your own CLI (`target: "claude-code"`) freely, alone or alongside the peers, and the same goes
68
69
  for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
61
61
 
62
62
  **Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
63
63
  consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
64
- for the one the user asked for.
64
+ for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
65
+ "answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
65
66
 
66
67
  Consult your own CLI (`target: "codex"`) freely, alone or alongside the peers, and the same goes
67
68
  for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
@@ -61,7 +61,8 @@ actually reach. When the user names one, pass **the word the user used** as `tar
61
61
 
62
62
  **Do not pick a model yourself.** When the user named no one, ask everyone; overriding the operator's
63
63
  consultants on your own judgement — including after a `usage_limit` failure — substitutes a different mind
64
- for the one the user asked for.
64
+ for the one the user asked for. The operator may have set a fallback model for that case; a caveat reading
65
+ "answered by X: Y's quota was used up" means it answered, and your report names X as the one who answered.
65
66
 
66
67
  Consult your own CLI (`target: "opencode"`) freely, alone or alongside the peers, and the same goes
67
68
  for a **different model of your own lineage** when the user wants one (an Opus lead asking Fable). Either is a
@@ -47,6 +47,7 @@ export function renderConfig() {
47
47
  ' // effort reasoning effort (default high); antigravity takes it from the model name instead',
48
48
  " // timeout_ms this consultant's own budget in ms (default: server timeout_ms, max 7200000)",
49
49
  ' // max_budget_usd claude-code only: spending cap per consultation (default 10, 0.05-20)',
50
+ " // fallback_model a model to run instead, once, when model's quota is used up (usage_limit)",
50
51
  ' // vendor the lineage, when the model name does not say (e.g. "nvidia")',
51
52
  ' // enabled, note as for clis, for this consultant alone',
52
53
  ' //',
@@ -117,7 +118,7 @@ function main() {
117
118
  }
118
119
  console.log('');
119
120
  console.log('clis.<cli>: enabled note bin args env');
120
- console.log('consultants.<name>: cli model effort timeout_ms max_budget_usd vendor enabled note');
121
+ console.log('consultants.<name>: cli model effort timeout_ms max_budget_usd fallback_model vendor enabled note');
121
122
  console.log(' more: CONFIG.md (what each key does), config.example.json (a working example).');
122
123
  console.log(' Restart the client after editing.');
123
124
  console.log(' comments and trailing commas are fine; name it config.jsonc if your editor prefers that');
@@ -15,6 +15,7 @@
15
15
  * @property {string | null} effort
16
16
  * @property {number | null} timeoutMs
17
17
  * @property {number | null} maxBudgetUsd
18
+ * @property {string | null} fallbackModel
18
19
  * @property {string | null} vendor
19
20
  * @property {boolean} enabled
20
21
  * @property {string | null} note
@@ -333,7 +334,7 @@ export function sameModel(callerModel, consultantModel) {
333
334
 
334
335
  const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
335
336
  const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
336
- const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'vendor', 'enabled', 'note'];
337
+ const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'fallback_model', 'vendor', 'enabled', 'note'];
337
338
  /**
338
339
  * @param {unknown} v
339
340
  * @returns {v is Record<string, unknown>}
@@ -460,7 +461,7 @@ export function buildConsultants(config) {
460
461
  if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
461
462
  if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
462
463
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
463
- for (const k of ['effort', 'vendor', 'note']) {
464
+ for (const k of ['effort', 'fallback_model', 'vendor', 'note']) {
464
465
  if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
465
466
  }
466
467
  if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
@@ -468,6 +469,10 @@ export function buildConsultants(config) {
468
469
  if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
469
470
  problems.push(`${at}.${k}: must be a number`);
470
471
  }
472
+ if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === '')
473
+ problems.push(`${at}.fallback_model: must not be empty`);
474
+ else if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === String(def.model).trim())
475
+ problems.push(`${at}.fallback_model: must differ from model; the fallback is for when the model's quota is used up`);
471
476
  if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
472
477
  problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
473
478
  // agy takes the effort from the model name and rejects --effort.
@@ -500,6 +505,7 @@ export function buildConsultants(config) {
500
505
  * @property {unknown} [effort]
501
506
  * @property {unknown} [timeout_ms]
502
507
  * @property {unknown} [max_budget_usd]
508
+ * @property {unknown} [fallback_model]
503
509
  * @property {unknown} [vendor]
504
510
  * @property {unknown} [enabled]
505
511
  * @property {unknown} [note]
@@ -519,6 +525,7 @@ function make(name, def) {
519
525
  effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
520
526
  timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
521
527
  maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
528
+ fallbackModel: typeof def.fallback_model === 'string' && def.fallback_model.trim() !== '' ? def.fallback_model.trim() : null,
522
529
  vendor: vendorOf(model, def.vendor),
523
530
  enabled: def.enabled !== false,
524
531
  note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
package/src/jobs.mjs CHANGED
@@ -1123,26 +1123,35 @@ export class JobManager {
1123
1123
  // its latency is part of this job's duration and budget, not free time
1124
1124
  // before the meter starts.
1125
1125
  if (adapter.prepare) await adapter.prepare();
1126
- const invocation = adapter.buildInvocation({
1127
- workdir,
1128
- schemaPath,
1129
- guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
1130
- sandbox,
1131
- consultant,
1132
- hasExposedPaths: Boolean(req._snapshot),
1133
- network: req.network,
1134
- });
1135
-
1136
- const argsProblem = extraArgsProblem(cli, invocation.args, t.extraArgs);
1137
- if (argsProblem) return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${argsProblem}`);
1138
- invocation.args = withExtraArgs(invocation.args, t.extraArgs);
1139
- assertNoForbiddenFlags(cli, invocation.args);
1140
- // What this consultant was actually launched with, read off the
1141
- // invocation and the sandbox rather than restated from intent, so the
1142
- // record says what held for this run.
1143
- job.boundary =
1144
- adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
1145
- null;
1126
+ // Built again for the fallback model, so the operator's extra args and
1127
+ // the forbidden-flag check apply to that run exactly as to the first.
1128
+ // Returns the invocation, or the problem with the operator's args.
1129
+ /** @param {import('./consultants.mjs').Consultant} c @returns {import('./run.mjs').Invocation | string} */
1130
+ const launch = (c) => {
1131
+ const invocation = adapter.buildInvocation({
1132
+ workdir,
1133
+ schemaPath,
1134
+ guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
1135
+ sandbox,
1136
+ consultant: c,
1137
+ hasExposedPaths: Boolean(req._snapshot),
1138
+ network: req.network,
1139
+ });
1140
+ const problem = extraArgsProblem(cli, invocation.args, t.extraArgs);
1141
+ if (problem) return problem;
1142
+ invocation.args = withExtraArgs(invocation.args, t.extraArgs);
1143
+ assertNoForbiddenFlags(cli, invocation.args);
1144
+ // What this consultant was actually launched with, read off the
1145
+ // invocation and the sandbox rather than restated from intent, so the
1146
+ // record says what held for this run.
1147
+ job.boundary =
1148
+ adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
1149
+ null;
1150
+ return invocation;
1151
+ };
1152
+ const first = launch(consultant);
1153
+ if (typeof first === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${first}`);
1154
+ let invocation = first;
1146
1155
 
1147
1156
  const attempt = (/** @type {number} */ timeout) => {
1148
1157
  const { handle, done } = runChild({
@@ -1176,6 +1185,7 @@ export class JobManager {
1176
1185
  let interpreted;
1177
1186
  let authTried = false;
1178
1187
  let autoRetries = 0;
1188
+ let fallenBack = false;
1179
1189
  for (;;) {
1180
1190
  // Read off the consultant's own event stream, last attempt only; null
1181
1191
  // where the CLI does not stream its tool calls (Claude Code).
@@ -1247,6 +1257,25 @@ export class JobManager {
1247
1257
  continue;
1248
1258
  }
1249
1259
  }
1260
+ // The operator's fallback for a model whose account quota is used up
1261
+ // (a re-run on the same model would fail the same way). It is a
1262
+ // different mind from the one asked, so the answer says so below.
1263
+ if (interpreted.failureKind === 'usage_limit' && consultant.fallbackModel && !fallenBack && remaining >= MIN_RETRY_MS) {
1264
+ fallenBack = true;
1265
+ const fallback = consultant.fallbackModel;
1266
+ const next = launch({ ...consultant, model: fallback });
1267
+ if (typeof next === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${next}`);
1268
+ invocation = next;
1269
+ job.retries.push({
1270
+ failure_kind: interpreted.failureKind,
1271
+ reason: `${job.model} quota used up; re-run on ${fallback}`,
1272
+ at: new Date().toISOString(),
1273
+ usage: adapter.usageRecord(interpreted.usageRaw),
1274
+ });
1275
+ job.model = fallback;
1276
+ run = await attempt(remaining);
1277
+ continue;
1278
+ }
1250
1279
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
1251
1280
  if (
1252
1281
  AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
@@ -1319,6 +1348,7 @@ export class JobManager {
1319
1348
  `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
1320
1349
  leftover &&
1321
1350
  '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)',
1351
+ fallenBack && `answered by ${job.model}: ${consultant.model}'s quota was used up`,
1322
1352
  req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
1323
1353
  refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS),
1324
1354
  ].filter(Boolean);
@@ -2106,8 +2136,12 @@ function unreadCaveat(calls, readTools) {
2106
2136
  return `files were exposed, but no call to read one succeeded (${how})`;
2107
2137
  }
2108
2138
 
2109
- // A refused call to a tool outside the allowlist is most often a tool the CLI
2110
- // renamed in a new release; naming it lets one consultation show the cause.
2139
+ // A refused call to a tool outside the allowlist that looks like a file
2140
+ // reader is most often the CLI's read tool renamed in a new release; naming
2141
+ // it lets one consultation show the cause. The tools severally refuses on
2142
+ // purpose (agy tries run_command even for a trivial question) are left out:
2143
+ // reporting them would be noise, and they are not renamed readers.
2144
+ // ponytail: a name heuristic; a reader named otherwise goes unreported until the unread note shows it.
2111
2145
  /**
2112
2146
  * @param {Record<string, number> | null | undefined} calls
2113
2147
  * @param {readonly string[] | undefined} allowed
@@ -2118,9 +2152,9 @@ function refusedToolCaveat(calls, allowed) {
2118
2152
  const names = Object.keys(calls)
2119
2153
  .filter((k) => k.endsWith(':error'))
2120
2154
  .map((k) => k.slice(0, -':error'.length))
2121
- .filter((t) => !allowed.includes(t));
2155
+ .filter((t) => !allowed.includes(t) && /read|view|open/i.test(t));
2122
2156
  if (!names.length) return null;
2123
- return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed a tool`;
2157
+ return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed its file-reading tool`;
2124
2158
  }
2125
2159
 
2126
2160
  // A lead may legitimately name its own CLI as the consultant: a fresh child
package/src/server.mjs CHANGED
@@ -11,7 +11,7 @@ import { exportChain, ExportError } from './export.mjs';
11
11
 
12
12
  /** @typedef {import('@modelcontextprotocol/server').CallToolResult} CallToolResult */
13
13
 
14
- export const SEVERALLY_VERSION = '2.4.2';
14
+ export const SEVERALLY_VERSION = '2.5.0';
15
15
 
16
16
  // consult_get waits this long when no wait_ms is given: a call that forgot it
17
17
  // would otherwise return at once and be repeated. Up to 45 s, under the 60 s