agent-nuvira 3.5.6 → 3.5.9
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/.agents/skills/index.json +16 -0
- package/.agents/skills/motion/SKILL.md +27 -0
- package/README.md +56 -1
- package/dist/agents/agents/package-agent.d.ts +22 -0
- package/dist/agents/agents/package-agent.d.ts.map +1 -1
- package/dist/agents/agents/package-agent.js +92 -4
- package/dist/agents/agents/package-agent.js.map +1 -1
- package/dist/agents/release-preflight.d.ts +42 -5
- package/dist/agents/release-preflight.d.ts.map +1 -1
- package/dist/agents/release-preflight.js +82 -5
- package/dist/agents/release-preflight.js.map +1 -1
- package/dist/cli/chat.d.ts +76 -17
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +357 -127
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/code-map.d.ts +51 -0
- package/dist/cli/code-map.d.ts.map +1 -1
- package/dist/cli/code-map.js +174 -2
- package/dist/cli/code-map.js.map +1 -1
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +4 -1
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/doctor.d.ts +31 -2
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +143 -7
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/execute.js +5 -5
- package/dist/cli/execute.js.map +1 -1
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +80 -14
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/parity.d.ts +12 -0
- package/dist/cli/parity.d.ts.map +1 -1
- package/dist/cli/parity.js +46 -1
- package/dist/cli/parity.js.map +1 -1
- package/dist/cli/tool-install-prompt.d.ts +2 -7
- package/dist/cli/tool-install-prompt.d.ts.map +1 -1
- package/dist/cli/tool-install-prompt.js +11 -28
- package/dist/cli/tool-install-prompt.js.map +1 -1
- package/dist/config/process-env.d.ts.map +1 -1
- package/dist/config/process-env.js +2 -2
- package/dist/config/process-env.js.map +1 -1
- package/dist/config/scratch-workspace.d.ts +134 -0
- package/dist/config/scratch-workspace.d.ts.map +1 -0
- package/dist/config/scratch-workspace.js +213 -0
- package/dist/config/scratch-workspace.js.map +1 -0
- package/dist/editing/types.d.ts +23 -1
- package/dist/editing/types.d.ts.map +1 -1
- package/dist/editing/types.js +51 -7
- package/dist/editing/types.js.map +1 -1
- package/dist/gateway/adapters.d.ts +24 -0
- package/dist/gateway/adapters.d.ts.map +1 -1
- package/dist/gateway/adapters.js +12 -0
- package/dist/gateway/adapters.js.map +1 -1
- package/dist/gateway/oversized-reply.d.ts +39 -0
- package/dist/gateway/oversized-reply.d.ts.map +1 -0
- package/dist/gateway/oversized-reply.js +54 -0
- package/dist/gateway/oversized-reply.js.map +1 -0
- package/dist/gateway/registry.d.ts +0 -10
- package/dist/gateway/registry.d.ts.map +1 -1
- package/dist/gateway/registry.js +148 -4
- package/dist/gateway/registry.js.map +1 -1
- package/dist/inference/route-resolver.d.ts +39 -0
- package/dist/inference/route-resolver.d.ts.map +1 -1
- package/dist/inference/route-resolver.js +51 -2
- package/dist/inference/route-resolver.js.map +1 -1
- package/dist/inference/thinking-replay.d.ts +86 -0
- package/dist/inference/thinking-replay.d.ts.map +1 -0
- package/dist/inference/thinking-replay.js +110 -0
- package/dist/inference/thinking-replay.js.map +1 -0
- package/dist/inference/tools.d.ts +9 -0
- package/dist/inference/tools.d.ts.map +1 -1
- package/dist/inference/tools.js +12 -1
- package/dist/inference/tools.js.map +1 -1
- package/dist/learning/assessment-protocol.d.ts +120 -0
- package/dist/learning/assessment-protocol.d.ts.map +1 -0
- package/dist/learning/assessment-protocol.js +236 -0
- package/dist/learning/assessment-protocol.js.map +1 -0
- package/dist/learning/assessment-turn-context.d.ts +54 -0
- package/dist/learning/assessment-turn-context.d.ts.map +1 -0
- package/dist/learning/assessment-turn-context.js +71 -0
- package/dist/learning/assessment-turn-context.js.map +1 -0
- package/dist/learning/exhaustion-report.d.ts +24 -1
- package/dist/learning/exhaustion-report.d.ts.map +1 -1
- package/dist/learning/exhaustion-report.js +18 -6
- package/dist/learning/exhaustion-report.js.map +1 -1
- package/dist/learning/perf-probe.d.ts +140 -0
- package/dist/learning/perf-probe.d.ts.map +1 -0
- package/dist/learning/perf-probe.js +316 -0
- package/dist/learning/perf-probe.js.map +1 -0
- package/dist/learning/quota-ledger.d.ts +24 -0
- package/dist/learning/quota-ledger.d.ts.map +1 -1
- package/dist/learning/quota-ledger.js +40 -0
- package/dist/learning/quota-ledger.js.map +1 -1
- package/dist/learning/runtime-dependencies.d.ts +183 -0
- package/dist/learning/runtime-dependencies.d.ts.map +1 -0
- package/dist/learning/runtime-dependencies.js +349 -0
- package/dist/learning/runtime-dependencies.js.map +1 -0
- package/dist/learning/self-check-discovery.d.ts +117 -0
- package/dist/learning/self-check-discovery.d.ts.map +1 -0
- package/dist/learning/self-check-discovery.js +334 -0
- package/dist/learning/self-check-discovery.js.map +1 -0
- package/dist/learning/skill-store.d.ts.map +1 -1
- package/dist/learning/skill-store.js +6 -5
- package/dist/learning/skill-store.js.map +1 -1
- package/dist/skills/bundled-skills-phase3.d.ts +14 -0
- package/dist/skills/bundled-skills-phase3.d.ts.map +1 -1
- package/dist/skills/bundled-skills-phase3.js +50 -0
- package/dist/skills/bundled-skills-phase3.js.map +1 -1
- package/dist/tools/child-agent-runtime.js +19 -1
- package/dist/tools/child-agent-runtime.js.map +1 -1
- package/dist/tools/credentials-tool.d.ts.map +1 -1
- package/dist/tools/credentials-tool.js +4 -1
- package/dist/tools/credentials-tool.js.map +1 -1
- package/dist/tools/loop-project-context.d.ts +7 -0
- package/dist/tools/loop-project-context.d.ts.map +1 -1
- package/dist/tools/loop-project-context.js +44 -0
- package/dist/tools/loop-project-context.js.map +1 -1
- package/dist/tools/modality/motion-presets.d.ts +62 -0
- package/dist/tools/modality/motion-presets.d.ts.map +1 -0
- package/dist/tools/modality/motion-presets.js +399 -0
- package/dist/tools/modality/motion-presets.js.map +1 -0
- package/dist/tools/modality/motion-tool.d.ts +62 -0
- package/dist/tools/modality/motion-tool.d.ts.map +1 -0
- package/dist/tools/modality/motion-tool.js +220 -0
- package/dist/tools/modality/motion-tool.js.map +1 -0
- package/dist/tools/modality/motion.d.ts +298 -0
- package/dist/tools/modality/motion.d.ts.map +1 -0
- package/dist/tools/modality/motion.js +514 -0
- package/dist/tools/modality/motion.js.map +1 -0
- package/dist/tools/pipeline-tool.d.ts.map +1 -1
- package/dist/tools/pipeline-tool.js +19 -1
- package/dist/tools/pipeline-tool.js.map +1 -1
- package/dist/tools/plan-store.d.ts +90 -6
- package/dist/tools/plan-store.d.ts.map +1 -1
- package/dist/tools/plan-store.js +140 -13
- package/dist/tools/plan-store.js.map +1 -1
- package/dist/tools/publish-tool.d.ts.map +1 -1
- package/dist/tools/publish-tool.js +9 -0
- package/dist/tools/publish-tool.js.map +1 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +100 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +17 -0
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +70 -17
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/tools/toolsets.js +2 -2
- package/dist/tools/toolsets.js.map +1 -1
- package/dist/utils/workspace-need.d.ts +84 -0
- package/dist/utils/workspace-need.d.ts.map +1 -0
- package/dist/utils/workspace-need.js +227 -0
- package/dist/utils/workspace-need.js.map +1 -0
- package/dist/web-dashboard/chat-console.d.ts +22 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +75 -11
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/hub-data.d.ts +9 -1
- package/dist/web-dashboard/hub-data.d.ts.map +1 -1
- package/dist/web-dashboard/hub-data.js +2 -0
- package/dist/web-dashboard/hub-data.js.map +1 -1
- package/dist/web-dashboard/project-context.d.ts +1 -1
- package/dist/web-dashboard/project-context.d.ts.map +1 -1
- package/dist/web-dashboard/project-context.js +13 -1
- package/dist/web-dashboard/project-context.js.map +1 -1
- package/dist/web-dashboard/server.d.ts +33 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +229 -24
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +46 -0
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.d.ts +20 -21
- package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.js +20 -153
- package/dist/web-dashboard/workspace-guard.js.map +1 -1
- package/dist/web-dashboard/workspace-resolution.d.ts +23 -5
- package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-resolution.js +22 -4
- package/dist/web-dashboard/workspace-resolution.js.map +1 -1
- package/package.json +1 -1
- package/src/web-dashboard/public/assets/index-CGYIlLpO.js +236 -0
- package/src/web-dashboard/public/assets/index-CGYIlLpO.js.map +1 -0
- package/src/web-dashboard/public/assets/{index-DeSsNU94.css → index-CzRU84ET.css} +1 -1
- package/src/web-dashboard/public/index.html +2 -2
- package/src/web-dashboard/public/assets/index-Do_O67Kv.js +0 -236
- package/src/web-dashboard/public/assets/index-Do_O67Kv.js.map +0 -1
package/dist/cli/chat.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from 'commander';
|
|
|
5
5
|
import inquirer from 'inquirer';
|
|
6
6
|
import { BaseCommand } from './commands.js';
|
|
7
7
|
import { resolveProvider } from './router.js';
|
|
8
|
-
import { resolveRoute, strictModelMode, strictPinRefusal } from '../inference/route-resolver.js';
|
|
8
|
+
import { resolveRoute, strictModelMode, strictPinPolicy, strictPinRefusal, withStrictModel } from '../inference/route-resolver.js';
|
|
9
9
|
import { showModelPicker } from './model-picker.js';
|
|
10
10
|
import { ContextParser } from '../context/parser.js';
|
|
11
11
|
import { getCache } from '../context/cache.js';
|
|
@@ -32,10 +32,12 @@ import { UnusableModelResponseError } from '../learning/response-usability.js';
|
|
|
32
32
|
import { resolveThreadBudgetChars } from '../learning/context-budget.js';
|
|
33
33
|
import { getAutoRouter, isAgentAutoRoute, isAutoModel, isAutoProvider, governanceVerdict, adminBudgetVerdict, isAgenticTask } from '../learning/auto-router.js';
|
|
34
34
|
import { continuationSoftwareText } from '../learning/continuation-intent.js';
|
|
35
|
+
import { buildAssessmentTurnContext } from '../learning/assessment-turn-context.js';
|
|
35
36
|
import { TaskModelContinuity, taskSignature } from '../learning/task-model-continuity.js';
|
|
36
37
|
import { describeFailureKind, renderExhaustionReport, } from '../learning/exhaustion-report.js';
|
|
37
38
|
import { estimateTokens } from '../learning/cost-tracker.js';
|
|
38
39
|
import { getModelRegistry } from '../learning/model-registry.js';
|
|
40
|
+
import { getQuotaLedger } from '../learning/quota-ledger.js';
|
|
39
41
|
import { costSince } from '../learning/cost-tracker.js';
|
|
40
42
|
import { refreshModelRegistry } from '../inference/model-probe.js';
|
|
41
43
|
import { startWarmupDaemon } from '../learning/model-warmup.js';
|
|
@@ -484,6 +486,44 @@ export const PINNED_SAME_PAIR_GRACE_MS = 120_000;
|
|
|
484
486
|
* these reach the {@link PINNED_SAME_PAIR_GRACE_MS} budget.
|
|
485
487
|
*/
|
|
486
488
|
export const PINNED_GRACE_BACKOFF_MS = [5_000, 15_000, 30_000, 45_000];
|
|
489
|
+
/**
|
|
490
|
+
* The whole pinned-escalation budget, read as CONFIGURATION rather than a build
|
|
491
|
+
* property — the same rule {@link transientRetryDelaysMs} follows, and for the
|
|
492
|
+
* same reason.
|
|
493
|
+
*
|
|
494
|
+
* `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` is the dial every headless surface already
|
|
495
|
+
* sets (the gateway's lifecycle contracts, the parity driver, CI): it says "a
|
|
496
|
+
* flapping provider costs milliseconds here, not minutes". The pinned window is
|
|
497
|
+
* the SAME KIND of wait — a bounded retry of one pair, followed by a question no
|
|
498
|
+
* unattended surface can answer — so it has to honour that dial. Measured
|
|
499
|
+
* 2026-10-15: the dial was in force and four suites still timed out, because the
|
|
500
|
+
* pinned window ignored it — a gateway inbound message sat 95s on a dead pin and
|
|
501
|
+
* then held the turn open for the five-minute answer window.
|
|
502
|
+
*
|
|
503
|
+
* Set (and not nonsense) → the dial IS the pinned schedule: the window is the sum
|
|
504
|
+
* of the waits it declares, and the answer window collapses to the same budget,
|
|
505
|
+
* so a dialed run can never wait longer than it asked for. Unset → the shipped
|
|
506
|
+
* two-minute window and the five-minute answer window stand unchanged (see
|
|
507
|
+
* {@link PINNED_SAME_PAIR_GRACE_MS} / {@link PINNED_ANSWER_TIMEOUT_MS}).
|
|
508
|
+
*/
|
|
509
|
+
export function pinnedGraceWindow(env = process.env) {
|
|
510
|
+
if (!transientRetryScheduleOverridden(env)) {
|
|
511
|
+
return {
|
|
512
|
+
graceMs: PINNED_SAME_PAIR_GRACE_MS,
|
|
513
|
+
backoffMs: PINNED_GRACE_BACKOFF_MS,
|
|
514
|
+
answerTimeoutMs: PINNED_ANSWER_TIMEOUT_MS,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const dialed = transientRetryDelaysMs(env);
|
|
518
|
+
// Floored at 1ms so a `0` dial still buys one retry rather than an empty window
|
|
519
|
+
// (the same attempt the undialed path always makes first).
|
|
520
|
+
const graceMs = Math.max(1, dialed.reduce((sum, ms) => sum + ms, 0));
|
|
521
|
+
return { graceMs, backoffMs: dialed, answerTimeoutMs: graceMs };
|
|
522
|
+
}
|
|
523
|
+
/** A wait as a human reads it — seconds below a minute, minutes above. */
|
|
524
|
+
function describeWait(ms) {
|
|
525
|
+
return ms >= 60_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1000)}s`;
|
|
526
|
+
}
|
|
487
527
|
/**
|
|
488
528
|
* Failure kinds a WAIT can plausibly fix.
|
|
489
529
|
*
|
|
@@ -1266,13 +1306,29 @@ export class ChatCommand extends BaseCommand {
|
|
|
1266
1306
|
.option('--keep-worktree', 'Keep the isolated worktree after the turn instead of removing it')
|
|
1267
1307
|
.option('--resume [id]', 'Replay the recorded steps of this ask whose input is unchanged instead of paying for them again (defaults to the record for this goal + directory; also asked for by NUVIRA_RESUME=1)')
|
|
1268
1308
|
.addHelpText('after', '\nModel routing:\n' +
|
|
1269
|
-
' Pinning -p/--provider (and -m/--model)
|
|
1270
|
-
'
|
|
1271
|
-
'
|
|
1272
|
-
'
|
|
1273
|
-
'
|
|
1309
|
+
' Pinning -p/--provider (and -m/--model) HOLDS: the named model is the only one\n' +
|
|
1310
|
+
' that serves the turn, and a pin that cannot answer fails with a message\n' +
|
|
1311
|
+
' naming the pair instead of quietly substituting another model. To allow the\n' +
|
|
1312
|
+
' repair-and-announce behaviour instead (fall over to an available model and\n' +
|
|
1313
|
+
' report which one answered), set NUVIRA_STRICT_MODEL=0.\n')
|
|
1274
1314
|
.action(async (prompt, options) => {
|
|
1275
|
-
|
|
1315
|
+
const opts = options || {};
|
|
1316
|
+
// ── ASK 1 — A NAMED PAIR HOLDS FOR THE WHOLE TURN ──────────────────
|
|
1317
|
+
//
|
|
1318
|
+
// `--provider`/`--model` name a model; they are not a hint. The `auto`
|
|
1319
|
+
// sentinels are the opposite of a pin and are excluded. When a pair IS
|
|
1320
|
+
// named, the pin's policy (see `strictPinPolicy`) is scoped over the whole
|
|
1321
|
+
// turn — the one-shot answer, its picked followups and every REPL message —
|
|
1322
|
+
// so route resolution, the loop engine's candidate walk and chat's own
|
|
1323
|
+
// pinned-fallback path all read the same answer instead of each deciding
|
|
1324
|
+
// for itself whether substituting is acceptable. `NUVIRA_STRICT_MODEL=0`
|
|
1325
|
+
// is the explicit opt-out and keeps the old repair-and-announce behaviour.
|
|
1326
|
+
const namedPair = Boolean(opts.provider && !isAutoProvider(opts.provider)) || Boolean(opts.model && !isAutoModel(opts.model));
|
|
1327
|
+
if (namedPair && strictPinPolicy(true)) {
|
|
1328
|
+
await withStrictModel(true, () => this.execute(prompt, opts));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
await this.execute(prompt, opts);
|
|
1276
1332
|
});
|
|
1277
1333
|
return command;
|
|
1278
1334
|
}
|
|
@@ -1806,10 +1862,46 @@ export class ChatCommand extends BaseCommand {
|
|
|
1806
1862
|
* about one directory.
|
|
1807
1863
|
*/
|
|
1808
1864
|
const turnScope = ctxOverrides?.projectPath || process.cwd();
|
|
1865
|
+
// ── ASSESSMENT TURN — one detector, three consumers ────────────────────
|
|
1866
|
+
//
|
|
1867
|
+
// The evidence protocol and everything it carries (the project's own
|
|
1868
|
+
// discovered checks, the performance probes) comes from the SHARED assembler,
|
|
1869
|
+
// which is also what `loop-executor.ts` and `pipeline-tool.ts` call — so
|
|
1870
|
+
// `nuvira execute` and a gateway-run audit get the same rules as this surface
|
|
1871
|
+
// instead of an unadorned ask. See `learning/assessment-turn-context.ts`.
|
|
1872
|
+
//
|
|
1873
|
+
// The symbol map in the ambient project context (which a findings turn repays
|
|
1874
|
+
// and a change turn does not) and the response-cache bypass below read the SAME
|
|
1875
|
+
// boolean, so they can never disagree about whether the turn is an assessment.
|
|
1876
|
+
// Computed here because the cache check is the first thing that needs it.
|
|
1877
|
+
//
|
|
1878
|
+
// Deterministic and NARROW: the assembler tests the goal BEFORE it touches the
|
|
1879
|
+
// filesystem, so a change request returns '' and does no discovery work — its
|
|
1880
|
+
// turn is byte-identical to one from a build without any of this.
|
|
1881
|
+
const assessmentContext = buildAssessmentTurnContext(message, turnScope);
|
|
1882
|
+
const assessmentBlock = assessmentContext.block;
|
|
1883
|
+
const assessmentTurn = assessmentContext.isAssessment;
|
|
1809
1884
|
// Cache check first (same as the legacy path).
|
|
1810
1885
|
const cache = getCache();
|
|
1811
1886
|
const cacheModel = this.cacheModelFor(session);
|
|
1812
|
-
|
|
1887
|
+
// ASSESSMENT TURNS DO NOT REPLAY A CACHED ANSWER.
|
|
1888
|
+
//
|
|
1889
|
+
// A cached answer is a statement about the tree AS IT WAS when the entry was
|
|
1890
|
+
// written, and an assessment is a statement about the tree NOW — so the key
|
|
1891
|
+
// (prompt + model + provider + workspace) is exactly the four things that stay
|
|
1892
|
+
// equal when the code has changed underneath. Observed on 2026-10-16: after a
|
|
1893
|
+
// patched build was installed, the same audit prompt in the same directory
|
|
1894
|
+
// returned `cache.hit {chars:5744}` — the PRE-patch report, identical down to a
|
|
1895
|
+
// mid-sentence truncation, replayed as if it were a fresh look at the tree.
|
|
1896
|
+
//
|
|
1897
|
+
// The cost is one real turn where a replay used to be instant, and it is only
|
|
1898
|
+
// paid by assessment turns: a change request, a question, a repeat of anything
|
|
1899
|
+
// else keeps its cache hit. Truth over speed, in the one place the two can
|
|
1900
|
+
// disagree about the same input.
|
|
1901
|
+
if (cacheEnabled && assessmentTurn) {
|
|
1902
|
+
debugLog?.event('cache.bypass', { reason: 'assessment turn — the tree may have changed', scope: turnScope });
|
|
1903
|
+
}
|
|
1904
|
+
if (cacheEnabled && !assessmentTurn) {
|
|
1813
1905
|
try {
|
|
1814
1906
|
// #30 — read the entry WITH its recorded activity, not just the text: a
|
|
1815
1907
|
// replay that dropped `toolCalls` rendered no tool cards on the dashboard
|
|
@@ -2003,7 +2095,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
2003
2095
|
let machineBlock = '';
|
|
2004
2096
|
try {
|
|
2005
2097
|
const { buildMachineFactsBlock } = await import('../learning/machine-facts.js');
|
|
2006
|
-
|
|
2098
|
+
// …plus the optional tools this machine does NOT have, so a feature that
|
|
2099
|
+
// needs one is named as skipped (with its install command) instead of
|
|
2100
|
+
// being reported as done. Empty string when everything is installed.
|
|
2101
|
+
const { buildRuntimeDependenciesBlock } = await import('../learning/runtime-dependencies.js');
|
|
2102
|
+
machineBlock = buildMachineFactsBlock() + buildRuntimeDependenciesBlock();
|
|
2007
2103
|
}
|
|
2008
2104
|
catch {
|
|
2009
2105
|
machineBlock = '';
|
|
@@ -2035,7 +2131,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
2035
2131
|
let ambientProjectContext;
|
|
2036
2132
|
if (!ctxOverrides?.projectContext) {
|
|
2037
2133
|
try {
|
|
2038
|
-
const built = await buildLoopProjectContext(turnCwd);
|
|
2134
|
+
const built = await buildLoopProjectContext(turnCwd, { symbolMap: assessmentTurn });
|
|
2039
2135
|
if (built)
|
|
2040
2136
|
ambientProjectContext = built;
|
|
2041
2137
|
}
|
|
@@ -2101,6 +2197,8 @@ export class ChatCommand extends BaseCommand {
|
|
|
2101
2197
|
// conversation stays contiguous, and it is a USER-TURN message rather than
|
|
2102
2198
|
// system text, so the system prompt stays byte-stable and cacheable.
|
|
2103
2199
|
...(knowledgeBlock ? [{ role: 'user', content: knowledgeBlock }] : []),
|
|
2200
|
+
// The assessment protocol for this ask, when the ask IS an assessment.
|
|
2201
|
+
...(assessmentBlock ? [{ role: 'user', content: assessmentBlock }] : []),
|
|
2104
2202
|
// P4 — the recalled project context (prior sessions + facts) rides in
|
|
2105
2203
|
// next, so the model starts from what this project was last doing.
|
|
2106
2204
|
...(ctxOverrides?.recallContext
|
|
@@ -2355,6 +2453,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
2355
2453
|
// Machine facts are FACTS about the host, not a recommendation, so they
|
|
2356
2454
|
// drop LAST among the optional blocks (higher priority = kept longer).
|
|
2357
2455
|
{ name: 'machine-facts', chars: machineBlock.length, dropPriority: 15 },
|
|
2456
|
+
// The assessment protocol governs how THIS turn's findings may be
|
|
2457
|
+
// claimed, so it outranks every other optional block and is dropped only
|
|
2458
|
+
// after the machine facts.
|
|
2459
|
+
{ name: 'assessment-protocol', chars: assessmentBlock.length, dropPriority: 18 },
|
|
2358
2460
|
{ name: 'working-state', chars: workingStateBlock.length, dropPriority: 25 },
|
|
2359
2461
|
{ name: 'decisions', chars: ctxOverrides?.decisionContext?.length ?? 0, dropPriority: 28 },
|
|
2360
2462
|
{ name: 'recall', chars: ctxOverrides?.recallContext?.length ?? 0, dropPriority: 30 },
|
|
@@ -2378,6 +2480,16 @@ export class ChatCommand extends BaseCommand {
|
|
|
2378
2480
|
(trimmed.has('skill-hint') ? '' : skillHint),
|
|
2379
2481
|
};
|
|
2380
2482
|
}
|
|
2483
|
+
// Applied rather than merely reported (as the two system blocks above are):
|
|
2484
|
+
// a protocol the budget says to drop must not still be in the thread, or the
|
|
2485
|
+
// report and the wire disagree. Matched by exact content so no other
|
|
2486
|
+
// user-turn block is ever touched.
|
|
2487
|
+
if (trimmed.has('assessment-protocol') && assessmentBlock) {
|
|
2488
|
+
for (let i = thread.length - 1; i >= 1; i--) {
|
|
2489
|
+
if (thread[i].role === 'user' && thread[i].content === assessmentBlock)
|
|
2490
|
+
thread.splice(i, 1);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2381
2493
|
if (report.level !== 'ok') {
|
|
2382
2494
|
recordTraceEvent(chatTraceId, {
|
|
2383
2495
|
kind: 'decision',
|
|
@@ -2574,52 +2686,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
2574
2686
|
}
|
|
2575
2687
|
return false;
|
|
2576
2688
|
}
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
const pick = sameClassFallback(pinModel, [
|
|
2604
|
-
...(base ? [base] : []),
|
|
2605
|
-
...preferredModelsFor(resolved.type),
|
|
2606
|
-
]);
|
|
2607
|
-
const model = pick?.model ?? base;
|
|
2608
|
-
if (pick?.crossClass) {
|
|
2609
|
-
logger.warn(` ⚠️ ${pinModel ?? 'the pinned model'} is not available anywhere; handing over to ` +
|
|
2610
|
-
`${resolved.type}/${model} (${capabilityClassOf(model)} class — no same-class model was available).`);
|
|
2611
|
-
}
|
|
2612
|
-
if (install({ type: resolved.type, provider: resolved.provider, model }))
|
|
2613
|
-
return true;
|
|
2614
|
-
}
|
|
2615
|
-
catch {
|
|
2616
|
-
// Next fallback candidate.
|
|
2617
|
-
}
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
catch {
|
|
2621
|
-
// Fall through to `false` — the loop's bounded path still applies.
|
|
2622
|
-
}
|
|
2689
|
+
/**
|
|
2690
|
+
* ── A PINNED TURN DOES NOT SUBSTITUTE ON AN UNUSABLE RESPONSE ────────
|
|
2691
|
+
*
|
|
2692
|
+
* This used to walk the configured fallback chain for every non-strict pin
|
|
2693
|
+
* (and refuse only under strict), so the pair the user pinned could be
|
|
2694
|
+
* replaced mid-turn by whatever the health ranking liked. Measured live
|
|
2695
|
+
* (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro` returned an empty
|
|
2696
|
+
* response twice, and the turn was answered by `local/deepseek-coder:latest`
|
|
2697
|
+
* — a weaker model the user never named, on a conversation that was pinned
|
|
2698
|
+
* with its "this model only" lock on but unpersisted (see
|
|
2699
|
+
* `ChatConsole`'s `pinnedStrict`). The switch also arrived as a handoff note
|
|
2700
|
+
* the model wrote to ITSELF; the user's only clue was the answer's wording.
|
|
2701
|
+
*
|
|
2702
|
+
* The pinned rule is now the same one {@link pinnedEscalation} implements for
|
|
2703
|
+
* a thrown failure: nothing is substituted without the user choosing it. An
|
|
2704
|
+
* EMPTY response cannot ask from here — the loop calls this hook but owns the
|
|
2705
|
+
* thread, so this function has no call to retry with — which is why the
|
|
2706
|
+
* caller's `emptyResponsePatienceMs` gives the pinned pair its full grace
|
|
2707
|
+
* window FIRST (the loop re-asks the SAME pair, nothing routed elsewhere), and
|
|
2708
|
+
* what is left when that window is spent is an honest stop, reported by the
|
|
2709
|
+
* surface with its retry offer. Routing a pinned turn to another model behind
|
|
2710
|
+
* the user's back is the defect, not the remedy.
|
|
2711
|
+
*
|
|
2712
|
+
* AUTO turns are untouched above: they are the case where substituting IS the
|
|
2713
|
+
* user's instruction (and the case the walk exists for).
|
|
2714
|
+
*/
|
|
2623
2715
|
return false;
|
|
2624
2716
|
};
|
|
2625
2717
|
try {
|
|
@@ -2648,6 +2740,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
2648
2740
|
// and is now additionally gated on the model having the context for it.
|
|
2649
2741
|
toolExposure: harness.exposure,
|
|
2650
2742
|
maxParallelReads: harness.maxParallelReads,
|
|
2743
|
+
// An assessment turn has no edits to verify, so the loop's self-review
|
|
2744
|
+
// gate would never fire for it — see `selfReviewWhenUnchanged`.
|
|
2745
|
+
...(assessmentTurn ? { selfReviewWhenUnchanged: true } : {}),
|
|
2651
2746
|
// WS3 — the turn span the loop hangs each tool call under.
|
|
2652
2747
|
otel: otelSpan,
|
|
2653
2748
|
// WS4 — the label a tool hook reports this call under.
|
|
@@ -2680,6 +2775,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
2680
2775
|
provider: session.type,
|
|
2681
2776
|
model: this.lastAttempt?.model ?? session.model ?? 'default',
|
|
2682
2777
|
};
|
|
2778
|
+
const failedEarlierStep = this.turnAttempts.some((a) => a.provider === pair.provider && a.model === pair.model);
|
|
2779
|
+
/**
|
|
2780
|
+
* ── A PINNED PAIR GETS THE PIN'S OWN GRACE WINDOW ─────────────
|
|
2781
|
+
*
|
|
2782
|
+
* An empty 200 is a PROVIDER failure, and for a PIN it is the one
|
|
2783
|
+
* failure the user already told us how to handle: wait on the model
|
|
2784
|
+
* they named. The blip policy's [2s, 5s] is the wrong instrument for
|
|
2785
|
+
* that contract — it is sized for "is this provider having a
|
|
2786
|
+
* moment", not for "you asked for THIS model".
|
|
2787
|
+
*
|
|
2788
|
+
* Measured (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro`
|
|
2789
|
+
* returned nothing, was re-asked after 2s and after 5s, and the turn
|
|
2790
|
+
* was then handed to `groq/openai/gpt-oss-120b` and finally answered
|
|
2791
|
+
* by `local/deepseek-coder:latest` — seven seconds of patience and a
|
|
2792
|
+
* model the user never picked. The strict path had a two-minute
|
|
2793
|
+
* window for this; an empty response could never reach it (the loop
|
|
2794
|
+
* never throws on one), so the pin got the blip schedule instead.
|
|
2795
|
+
*
|
|
2796
|
+
* So the pinned schedule is the SAME one {@link pinnedEscalation}
|
|
2797
|
+
* uses for a thrown failure — nothing is routed anywhere inside it —
|
|
2798
|
+
* and only for the FIRST streak on the pair: once the pair has
|
|
2799
|
+
* already failed a step, waiting again is a pattern, not a blip, and
|
|
2800
|
+
* the loop's own bounded stop follows.
|
|
2801
|
+
*/
|
|
2802
|
+
if (!mode.auto && !failedEarlierStep)
|
|
2803
|
+
return [...pinnedGraceWindow().backoffMs];
|
|
2683
2804
|
return planBlipPatience({
|
|
2684
2805
|
current: pair,
|
|
2685
2806
|
alternatives: failoverAlternatives(this.configManager, pair.provider),
|
|
@@ -2687,7 +2808,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
2687
2808
|
// An entry here means an EARLIER step of this turn already failed
|
|
2688
2809
|
// on this pair — the same pair failing twice is a pattern, not a
|
|
2689
2810
|
// blip, and patience collapses there.
|
|
2690
|
-
failedEarlierStep
|
|
2811
|
+
failedEarlierStep,
|
|
2691
2812
|
deliveredWorkThisTurn,
|
|
2692
2813
|
}).emptyResponseDelaysMs;
|
|
2693
2814
|
}
|
|
@@ -2841,6 +2962,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
2841
2962
|
// respects the quality bar they set instead of inviting a downgrade
|
|
2842
2963
|
// (see ExhaustionInput.pinnedModel).
|
|
2843
2964
|
...(session.model && session.model !== 'default' ? { pinnedModel: session.model } : {}),
|
|
2965
|
+
// ...and whether that pin is HELD, which decides whether the report
|
|
2966
|
+
// may promise an automatic next attempt at all. A held pin is the
|
|
2967
|
+
// default for a named pair, so this is the common case, and telling
|
|
2968
|
+
// the user to wait for a switch the pin forbids was the measured bug
|
|
2969
|
+
// (see ExhaustionInput.pinnedHeld). Read from the same predicate
|
|
2970
|
+
// routing obeys, in this turn's own scope.
|
|
2971
|
+
...(session.model && session.model !== 'default' && strictModelMode()
|
|
2972
|
+
? { pinnedHeld: true }
|
|
2973
|
+
: {}),
|
|
2844
2974
|
// The underlying line is kept as the REASON, so a specific,
|
|
2845
2975
|
// actionable cause is never buried by the report.
|
|
2846
2976
|
...(result.generationFailed && result.content.trim() ? { cause: result.content } : {}),
|
|
@@ -3025,6 +3155,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
3025
3155
|
if (turnCarriesHonestyFlag(result)) {
|
|
3026
3156
|
debugLog?.event('cache.skip', { reason: 'honesty-flag', scope: turnScope });
|
|
3027
3157
|
}
|
|
3158
|
+
else if (assessmentTurn) {
|
|
3159
|
+
// Same rule as the honesty flag above, for the same reason: an
|
|
3160
|
+
// assessment is a claim about the tree at THIS moment, so storing it
|
|
3161
|
+
// lets a later read of that entry — another surface, or a path without
|
|
3162
|
+
// this detector — replay it as a fresh look at code that may have
|
|
3163
|
+
// changed underneath it. The turn is not replayed and not stored; it is
|
|
3164
|
+
// re-derived.
|
|
3165
|
+
debugLog?.event('cache.skip', { reason: 'assessment turn — tree-scoped claim', scope: turnScope });
|
|
3166
|
+
}
|
|
3028
3167
|
else {
|
|
3029
3168
|
// Keyed by the model that ACTUALLY answered (tryGenerate records it
|
|
3030
3169
|
// on success), so a weak model's reply is never replayed as a strong
|
|
@@ -3314,44 +3453,81 @@ export class ChatCommand extends BaseCommand {
|
|
|
3314
3453
|
}
|
|
3315
3454
|
}
|
|
3316
3455
|
/**
|
|
3317
|
-
* PINNED
|
|
3318
|
-
*
|
|
3456
|
+
* A PINNED turn whose pair did not answer: wait on the SAME model, then let the
|
|
3457
|
+
* USER decide — never a silent substitution.
|
|
3458
|
+
*
|
|
3459
|
+
* WHY THIS EXISTS. Pinning a model is a quality contract, and the old behaviour
|
|
3460
|
+
* broke it in the one direction the user explicitly forbade: rather than wait, or
|
|
3461
|
+
* say what was happening, a failed pin either dead-ended in "the language model
|
|
3462
|
+
* was unavailable" or was handed to whatever the health ranking liked — including
|
|
3463
|
+
* `local/qwen2.5:0.5b`, a model that cannot call tools at all, so its "answer" was
|
|
3464
|
+
* fabricated rather than merely weaker. Sending that work to a weak model is not a
|
|
3465
|
+
* degraded answer; for an agentic turn it is a wrong one.
|
|
3319
3466
|
*
|
|
3320
|
-
*
|
|
3321
|
-
*
|
|
3322
|
-
*
|
|
3323
|
-
*
|
|
3324
|
-
*
|
|
3325
|
-
*
|
|
3326
|
-
*
|
|
3327
|
-
*
|
|
3467
|
+
* WHO IT COVERS. Every pin, not only a strict one. It used to be gated on
|
|
3468
|
+
* `strictModelMode()` alone, which left the default state — the chat's picker pin
|
|
3469
|
+
* with its lock OFF, which is what the client sends for `pinnedModel && !strictPin`
|
|
3470
|
+
* — on the silent path: measured live (trace-1792068695325-42wb1j) `deepseek/
|
|
3471
|
+
* deepseek-v4-pro` returned nothing twice, was handed to `groq/openai/gpt-oss-120b`
|
|
3472
|
+
* and from there to `local/deepseek-coder:latest`, and the turn was answered by a
|
|
3473
|
+
* model the user never named. So the wait-then-ask rule is now the PIN's, and the
|
|
3474
|
+
* STRICT flag only decides what happens when nobody can be asked.
|
|
3328
3475
|
*
|
|
3329
3476
|
* So: the SAME pair is retried inside {@link PINNED_SAME_PAIR_GRACE_MS} first
|
|
3330
3477
|
* (nothing is routed anywhere in that window), and if it still does not answer
|
|
3331
3478
|
* the user is asked — keep trying, switch to the closest SAME-CLASS model the
|
|
3332
|
-
* agent can find and name, type another model/provider
|
|
3333
|
-
*
|
|
3479
|
+
* agent can find and name, or type another model/provider. The question says
|
|
3480
|
+
* outright that nothing has been routed elsewhere.
|
|
3334
3481
|
*
|
|
3335
|
-
*
|
|
3336
|
-
*
|
|
3337
|
-
*
|
|
3338
|
-
*
|
|
3482
|
+
* Outcomes (see {@link PinEscalationOutcome}): a served response when the user's
|
|
3483
|
+
* choice works; `stop` when the pin's own error is the honest outcome; and
|
|
3484
|
+
* `unattended` when NOBODY could be asked on a non-strict pin — the caller then
|
|
3485
|
+
* keeps its documented fallback walk, so a pipeline or worker that cannot answer
|
|
3486
|
+
* a question is not dead-ended by one.
|
|
3339
3487
|
*/
|
|
3340
|
-
async
|
|
3488
|
+
async pinnedEscalation(input) {
|
|
3341
3489
|
const { session } = input;
|
|
3342
3490
|
const pin = `${session.type}/${session.model ?? 'default'}`;
|
|
3343
3491
|
const kind = classifyFallbackError(input.err);
|
|
3492
|
+
// How long this deployment is willing to wait on a pin — the two-minute
|
|
3493
|
+
// default, or the `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` dial when it is set
|
|
3494
|
+
// (see `pinnedGraceWindow`).
|
|
3495
|
+
const { graceMs, backoffMs, answerTimeoutMs } = pinnedGraceWindow();
|
|
3496
|
+
/**
|
|
3497
|
+
* This path cannot serve the pin. STRICT stops on the pin's own error; a
|
|
3498
|
+
* non-strict pin falls through to the caller's fallback walk, which is the
|
|
3499
|
+
* behaviour it had before this rule existed. Consulted only for the cases
|
|
3500
|
+
* where NOBODY has been asked anything — a definitive failure, or a surface
|
|
3501
|
+
* with no one to ask. Once a question has actually been put to the user, the
|
|
3502
|
+
* answer (or their silence) is binding for both modes: substituting after
|
|
3503
|
+
* they chose to stay would be the same silent switch in a new disguise.
|
|
3504
|
+
*/
|
|
3505
|
+
const cannotServe = () => strictModelMode() ? { kind: 'stop' } : { kind: 'unattended' };
|
|
3506
|
+
/**
|
|
3507
|
+
* NOBODY CAN BE ASKED, and the pin is not strict: hand the turn straight back
|
|
3508
|
+
* to the caller's documented fallback walk.
|
|
3509
|
+
*
|
|
3510
|
+
* The grace window below exists to bridge the wait to a QUESTION — "your pin has
|
|
3511
|
+
* not answered, would you rather switch?". With no surface to put that question
|
|
3512
|
+
* to, the window buys nothing but delay: a cron job or queue worker that pinned
|
|
3513
|
+
* a model would stall two minutes on every provider blip and then take the same
|
|
3514
|
+
* walk it could have taken immediately. STRICT is the other case and keeps its
|
|
3515
|
+
* window: "this model or nothing" has no earlier alternative, so waiting on the
|
|
3516
|
+
* named pair is the only thing that contract can do.
|
|
3517
|
+
*/
|
|
3518
|
+
if (!input.askUser && !process.stdin.isTTY && !strictModelMode())
|
|
3519
|
+
return { kind: 'unattended' };
|
|
3344
3520
|
// A DEFINITIVE failure is not a delay (see PINNED_WAITABLE_KINDS) — and a model
|
|
3345
3521
|
// the provider does not serve, or a request-shape fault, is definitive even
|
|
3346
3522
|
// though the classifier can only call it `unknown` (measured: a pin to a
|
|
3347
3523
|
// nonexistent id spent the whole 2-minute grace window on a 400 that could
|
|
3348
3524
|
// never change).
|
|
3349
3525
|
if (!PINNED_WAITABLE_KINDS.has(kind))
|
|
3350
|
-
return
|
|
3526
|
+
return cannotServe();
|
|
3351
3527
|
if (kind === 'unknown') {
|
|
3352
3528
|
const message = input.err instanceof Error ? input.err.message : String(input.err);
|
|
3353
3529
|
if (isHarnessFault(input.err) || PINNED_DEFINITIVE_ERROR_RE.test(message))
|
|
3354
|
-
return
|
|
3530
|
+
return cannotServe();
|
|
3355
3531
|
}
|
|
3356
3532
|
const attemptPin = async () => {
|
|
3357
3533
|
try {
|
|
@@ -3371,30 +3547,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
3371
3547
|
// ── 1. The SAME pair gets its grace window. Nothing is routed anywhere. ──
|
|
3372
3548
|
let served = await attemptPin();
|
|
3373
3549
|
if (served)
|
|
3374
|
-
return served;
|
|
3550
|
+
return { kind: 'served', response: served };
|
|
3375
3551
|
const startedAt = Date.now();
|
|
3376
|
-
for (const backoff of
|
|
3552
|
+
for (const backoff of backoffMs) {
|
|
3377
3553
|
if (input.signal?.aborted)
|
|
3378
|
-
return
|
|
3379
|
-
if (Date.now() - startedAt + backoff >
|
|
3554
|
+
return { kind: 'stop' };
|
|
3555
|
+
if (Date.now() - startedAt + backoff > graceMs)
|
|
3380
3556
|
break;
|
|
3381
3557
|
// Visible on BOTH surfaces: the CLI reads it on the console, the dashboard
|
|
3382
3558
|
// renders the same line as a progress step.
|
|
3383
3559
|
logger.warn(` ⏳ your pinned ${pin} has not answered (${describeFailureKind(kind)}) — retrying it in ` +
|
|
3384
3560
|
`${Math.round(backoff / 1000)}s (${Math.round((Date.now() - startedAt) / 1000)}s of ` +
|
|
3385
|
-
`${Math.round(
|
|
3561
|
+
`${Math.round(graceMs / 1000)}s). Nothing has been sent to another model.`);
|
|
3386
3562
|
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
3387
3563
|
served = await attemptPin();
|
|
3388
3564
|
if (served)
|
|
3389
|
-
return served;
|
|
3565
|
+
return { kind: 'served', response: served };
|
|
3390
3566
|
}
|
|
3391
3567
|
// ── 2. ASK. The pin is the user's contract; only they may release it. ────
|
|
3392
3568
|
const alternatives = this.pinAlternatives(session.model, session.type);
|
|
3393
3569
|
const switchTarget = alternatives.sameClass[0];
|
|
3394
|
-
// Headless (a pipeline, a cron, a queue worker) has no one to ask
|
|
3395
|
-
//
|
|
3570
|
+
// Headless (a pipeline, a cron, a queue worker) has no one to ask. Choosing
|
|
3571
|
+
// silently FOR the user is the defect this fixes, so the pin is never
|
|
3572
|
+
// released here: a strict pin stops on its own error, and a non-strict pin
|
|
3573
|
+
// hands back to the caller's fallback walk — see `cannotServe`.
|
|
3396
3574
|
if (!input.askUser && !process.stdin.isTTY)
|
|
3397
|
-
return
|
|
3575
|
+
return cannotServe();
|
|
3398
3576
|
const ask = input.askUser ?? (await import('../tools/ask-user.js')).renderAskUser;
|
|
3399
3577
|
const choices = [
|
|
3400
3578
|
{
|
|
@@ -3417,11 +3595,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
3417
3595
|
const otherIdx = switchTarget ? 2 : 1;
|
|
3418
3596
|
// NOTE — there is deliberately NO "go to auto" option. It was considered and
|
|
3419
3597
|
// removed: it is the one choice that serves a model the user never named, and
|
|
3420
|
-
// a
|
|
3421
|
-
//
|
|
3422
|
-
//
|
|
3423
|
-
const question = `I have NOT sent this to another model — you pinned ${pin}
|
|
3424
|
-
`${pin} did not answer within ${Math.round(
|
|
3598
|
+
// a pin exists precisely to rule that out. A user who wants routing to decide
|
|
3599
|
+
// clears the pin, which is a decision they own and can see. Nothing in this
|
|
3600
|
+
// method can therefore serve a pair the user did not pick.
|
|
3601
|
+
const question = `I have NOT sent this to another model — you pinned ${pin}, so that choice is yours.\n` +
|
|
3602
|
+
`${pin} did not answer within ${Math.round(graceMs / 1000)}s (${describeFailureKind(kind)}).\n` +
|
|
3425
3603
|
(switchTarget
|
|
3426
3604
|
? `If you would rather switch, ${switchTarget.provider}/${switchTarget.model} is the closest same-class model available.`
|
|
3427
3605
|
: 'No same-class model is available to switch to right now.');
|
|
@@ -3435,7 +3613,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3435
3613
|
const raced = await Promise.race([
|
|
3436
3614
|
ask(question, choices, false).then((a) => ({ answered: true, a })),
|
|
3437
3615
|
new Promise((resolve) => {
|
|
3438
|
-
answerTimer = setTimeout(() => resolve({ answered: false }),
|
|
3616
|
+
answerTimer = setTimeout(() => resolve({ answered: false }), answerTimeoutMs);
|
|
3439
3617
|
// Never hold the process open for an answer that already arrived: an
|
|
3440
3618
|
// un-unref'd 5-minute timer kept the CLI alive long after the turn was
|
|
3441
3619
|
// finished (the `chat` command would appear to hang on exit).
|
|
@@ -3445,14 +3623,14 @@ export class ChatCommand extends BaseCommand {
|
|
|
3445
3623
|
if (answerTimer)
|
|
3446
3624
|
clearTimeout(answerTimer);
|
|
3447
3625
|
if (!raced.answered) {
|
|
3448
|
-
logger.warn(` ⌛ no answer to the pinned-model question after ${
|
|
3626
|
+
logger.warn(` ⌛ no answer to the pinned-model question after ${describeWait(answerTimeoutMs)} — ` +
|
|
3449
3627
|
`stopping on ${pin} rather than choosing for you.`);
|
|
3450
|
-
return
|
|
3628
|
+
return { kind: 'stop' };
|
|
3451
3629
|
}
|
|
3452
3630
|
answer = raced.a;
|
|
3453
3631
|
}
|
|
3454
3632
|
catch {
|
|
3455
|
-
return
|
|
3633
|
+
return { kind: 'stop' };
|
|
3456
3634
|
}
|
|
3457
3635
|
const chosen = typeof answer.index === 'number' ? answer.index : -1;
|
|
3458
3636
|
if (chosen === keepIdx) {
|
|
@@ -3462,18 +3640,22 @@ export class ChatCommand extends BaseCommand {
|
|
|
3462
3640
|
const retryStart = Date.now();
|
|
3463
3641
|
for (;;) {
|
|
3464
3642
|
if (input.signal?.aborted)
|
|
3465
|
-
return
|
|
3643
|
+
return { kind: 'stop' };
|
|
3466
3644
|
const again = await attemptPin();
|
|
3467
3645
|
if (again)
|
|
3468
|
-
return again;
|
|
3469
|
-
if (Date.now() - retryStart >=
|
|
3646
|
+
return { kind: 'served', response: again };
|
|
3647
|
+
if (Date.now() - retryStart >= graceMs)
|
|
3470
3648
|
break;
|
|
3471
|
-
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
3649
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(15_000, graceMs)));
|
|
3472
3650
|
}
|
|
3473
|
-
|
|
3651
|
+
// The user chose to stay on the pin and it still did not answer: the pin's
|
|
3652
|
+
// own failure is the outcome. Substituting now would be the very switch
|
|
3653
|
+
// they just declined.
|
|
3654
|
+
return { kind: 'stop' };
|
|
3474
3655
|
}
|
|
3475
3656
|
if (switchIdx >= 0 && chosen === switchIdx && switchTarget) {
|
|
3476
|
-
|
|
3657
|
+
const switched = await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
|
|
3658
|
+
return switched ? { kind: 'served', response: switched } : { kind: 'stop' };
|
|
3477
3659
|
}
|
|
3478
3660
|
if (chosen === otherIdx) {
|
|
3479
3661
|
const custom = (answer.custom ?? '').trim();
|
|
@@ -3482,12 +3664,12 @@ export class ChatCommand extends BaseCommand {
|
|
|
3482
3664
|
if (pair) {
|
|
3483
3665
|
const servedCustom = await this.serveChosenPair(input, pair.provider, pair.model);
|
|
3484
3666
|
if (servedCustom)
|
|
3485
|
-
return servedCustom;
|
|
3667
|
+
return { kind: 'served', response: servedCustom };
|
|
3486
3668
|
}
|
|
3487
3669
|
logger.warn(` ⚠️ could not use "${custom}" — check that the model is served by that provider.`);
|
|
3488
3670
|
}
|
|
3489
3671
|
}
|
|
3490
|
-
return
|
|
3672
|
+
return { kind: 'stop' };
|
|
3491
3673
|
}
|
|
3492
3674
|
/**
|
|
3493
3675
|
* E3b — the model-call step for the tool loop:
|
|
@@ -3859,6 +4041,33 @@ export class ChatCommand extends BaseCommand {
|
|
|
3859
4041
|
catch {
|
|
3860
4042
|
// Best-effort — bookkeeping must never break the turn.
|
|
3861
4043
|
}
|
|
4044
|
+
// ══ A PINNED TURN ASKS BEFORE IT LEAVES THE PIN ════════════════════
|
|
4045
|
+
//
|
|
4046
|
+
// The rule is the user's, not the mode's: a pin is a quality contract, so
|
|
4047
|
+
// the turn WAITS on the pair it was given and then puts the choice to the
|
|
4048
|
+
// user (keep trying / switch to a named same-class model / type another /
|
|
4049
|
+
// stop). It never slides onto a model they did not name — which is what
|
|
4050
|
+
// the non-strict pin used to do the moment the pair returned an error.
|
|
4051
|
+
//
|
|
4052
|
+
// STRICT keeps its extra meaning: when nobody can be asked (a pipeline, a
|
|
4053
|
+
// worker with no interactive surface) the refusal stands and the pin's own
|
|
4054
|
+
// error is the outcome, rather than a silent substitution. A NON-strict
|
|
4055
|
+
// pin in that position keeps the documented fallback walk below, so an
|
|
4056
|
+
// unattended run cannot be dead-ended by a question it cannot answer.
|
|
4057
|
+
if (!mode.auto) {
|
|
4058
|
+
const decision = await this.pinnedEscalation({
|
|
4059
|
+
session,
|
|
4060
|
+
err,
|
|
4061
|
+
tryGenerate,
|
|
4062
|
+
...(askUser ? { askUser } : {}),
|
|
4063
|
+
signal: abort,
|
|
4064
|
+
});
|
|
4065
|
+
if (decision.kind === 'served')
|
|
4066
|
+
return decision.response;
|
|
4067
|
+
if (decision.kind === 'stop')
|
|
4068
|
+
throw err;
|
|
4069
|
+
// 'unattended' → fall through to the non-auto walk below.
|
|
4070
|
+
}
|
|
3862
4071
|
// Auto mode: fail over across the ranked candidates (never stuck).
|
|
3863
4072
|
if (mode.auto) {
|
|
3864
4073
|
const firstType = session.type;
|
|
@@ -3894,13 +4103,52 @@ export class ChatCommand extends BaseCommand {
|
|
|
3894
4103
|
const firstPair = `${this.lastAttempt?.provider ?? session.type}|${this.lastAttempt?.model ?? session.model ?? 'default'}`;
|
|
3895
4104
|
const failedPairs = new Set([firstPair]);
|
|
3896
4105
|
const failedProviders = new Set();
|
|
4106
|
+
/**
|
|
4107
|
+
* ── A PROVIDER-WIDE PARK IS EVIDENCE, NOT A HINT ─────────────────
|
|
4108
|
+
*
|
|
4109
|
+
* RULE: every provider the shared bookkeeping has escalated OUT is
|
|
4110
|
+
* left behind by this walk, whether the escalation lives in the
|
|
4111
|
+
* session's own map (this turn's failures) or in the quota ledger
|
|
4112
|
+
* (`parkProvider` — several of the provider's models rate-limited, so
|
|
4113
|
+
* the limit is shared; see `PROVIDER_RATE_LIMIT_ESCALATION_MODELS`).
|
|
4114
|
+
*
|
|
4115
|
+
* THE DEFECT THIS CLOSES (measured, trace-1792068452654-3e1w8d). The
|
|
4116
|
+
* walk read ONLY the session map, so gemini's ledger park — scope
|
|
4117
|
+
* `'provider'`, written at 12:48:23 and good until 16:48 — was
|
|
4118
|
+
* invisible to it. The walk then spent its whole attempt budget
|
|
4119
|
+
* descending gemini's OWN ladder (10 distinct gemini models, all of
|
|
4120
|
+
* them 429ing) and landed on its weakest rung, `gemma-4-26b-a4b-it`,
|
|
4121
|
+
* while `deepseek/deepseek-v4-pro` and `deepseek-flash` sat verified
|
|
4122
|
+
* and unused. One provider's exhaustion must not consume the walk.
|
|
4123
|
+
*
|
|
4124
|
+
* The walk's own rule is unchanged: a PAIR failure keeps the provider
|
|
4125
|
+
* routable (its siblings have their own limits). This only adds the
|
|
4126
|
+
* provider-wide verdict the router's own quota feed already uses, so
|
|
4127
|
+
* "the provider is out" is read from ONE source of truth instead of
|
|
4128
|
+
* two.
|
|
4129
|
+
*/
|
|
4130
|
+
const providerIsOut = (providerType) => {
|
|
4131
|
+
try {
|
|
4132
|
+
if ((this.sessionFailedProviders.get(providerType) ?? 0) > Date.now())
|
|
4133
|
+
return true;
|
|
4134
|
+
}
|
|
4135
|
+
catch {
|
|
4136
|
+
// Unknown session state → fall through to the ledger verdict.
|
|
4137
|
+
}
|
|
4138
|
+
try {
|
|
4139
|
+
return getQuotaLedger().isProviderParked(providerType);
|
|
4140
|
+
}
|
|
4141
|
+
catch {
|
|
4142
|
+
// No ledger → no provider-wide verdict (pair-scoped walking stays).
|
|
4143
|
+
return false;
|
|
4144
|
+
}
|
|
4145
|
+
};
|
|
3897
4146
|
try {
|
|
3898
4147
|
// The bookkeeping already ran for the failure above: a provider still
|
|
3899
4148
|
// carrying an ACTIVE provider-scoped exclusion is out for this walk,
|
|
3900
4149
|
// whatever kind the failure was classified as.
|
|
3901
|
-
if ((
|
|
4150
|
+
if (providerIsOut(session.type))
|
|
3902
4151
|
failedProviders.add(session.type);
|
|
3903
|
-
}
|
|
3904
4152
|
}
|
|
3905
4153
|
catch {
|
|
3906
4154
|
// Best-effort — evidence must never break the walk.
|
|
@@ -4007,14 +4255,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
4007
4255
|
// Is the PROVIDER out, or only this PAIR? The bookkeeping just
|
|
4008
4256
|
// wrote the answer; read it rather than re-deriving the rule, so
|
|
4009
4257
|
// the walk and the exclusion filter can never disagree.
|
|
4010
|
-
|
|
4011
|
-
try {
|
|
4012
|
-
providerOut = (this.sessionFailedProviders.get(next.type) ?? 0) > Date.now();
|
|
4013
|
-
}
|
|
4014
|
-
catch {
|
|
4015
|
-
// Unknown scope → treat it as pair-scoped (the conservative
|
|
4016
|
-
// direction for staying routable).
|
|
4017
|
-
}
|
|
4258
|
+
const providerOut = providerIsOut(next.type);
|
|
4018
4259
|
if (providerOut)
|
|
4019
4260
|
failedProviders.add(next.type);
|
|
4020
4261
|
logger.debug(` ⚠️ ${next.type}/${next.model ?? 'default'} failed (${describeFailureKind(nextKind)}) — ` +
|
|
@@ -4237,21 +4478,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
4237
4478
|
.join('; ')}`);
|
|
4238
4479
|
}
|
|
4239
4480
|
}
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
*/
|
|
4245
|
-
const decided = await this.pinnedStrictEscalation({
|
|
4246
|
-
session,
|
|
4247
|
-
err,
|
|
4248
|
-
tryGenerate,
|
|
4249
|
-
...(askUser ? { askUser } : {}),
|
|
4250
|
-
signal: abort,
|
|
4251
|
-
});
|
|
4252
|
-
if (decided)
|
|
4253
|
-
return decided;
|
|
4254
|
-
}
|
|
4481
|
+
// NOTE: the pinned case is handled by `pinnedEscalation` ABOVE, before
|
|
4482
|
+
// this if/else — a strict pin therefore never reaches the non-auto walk
|
|
4483
|
+
// (it throws its own error), which is the behaviour that branch used to
|
|
4484
|
+
// implement on its own.
|
|
4255
4485
|
// Answer-quality resilience: every candidate failed (or none was
|
|
4256
4486
|
// tried) and the error carries the model's raw confused reply —
|
|
4257
4487
|
// deliver THAT instead of failing the whole turn. A confusing answer
|