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 +1 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- package/package.json +1 -1
- 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 +48 -19
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/scripts/init-config.mjs +2 -1
- package/src/consultants.mjs +9 -2
- package/src/jobs.mjs +58 -24
- package/src/server.mjs +1 -1
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
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.
|
|
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
|
package/config.example.json
CHANGED
|
@@ -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" }
|
package/docs/how-it-works.ja.md
CHANGED
|
@@ -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 のプロセスからも読めます。ほかのプロセスで
|
package/docs/how-it-works.md
CHANGED
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "severally",
|
|
4
|
-
"version": "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.
|
|
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
|
|
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
|
|
40955
|
-
|
|
40956
|
-
|
|
40957
|
-
|
|
40958
|
-
|
|
40959
|
-
|
|
40960
|
-
|
|
40961
|
-
|
|
40962
|
-
|
|
40963
|
-
|
|
40964
|
-
|
|
40965
|
-
|
|
40966
|
-
|
|
40967
|
-
|
|
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.
|
|
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
|
package/scripts/init-config.mjs
CHANGED
|
@@ -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');
|
package/src/consultants.mjs
CHANGED
|
@@ -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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
2110
|
-
// renamed in a new release; naming
|
|
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
|
|
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.
|
|
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
|