agent-nuvira 3.5.5 → 3.5.7
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 +122 -1
- package/dist/cli/chat.d.ts +76 -17
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +330 -137
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +17 -3
- package/dist/cli/config.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.d.ts.map +1 -1
- package/dist/cli/execute.js +63 -22
- package/dist/cli/execute.js.map +1 -1
- package/dist/cli/failover-runner.js +4 -4
- package/dist/cli/failover-runner.js.map +1 -1
- package/dist/cli/gateway.js +13 -13
- package/dist/cli/gateway.js.map +1 -1
- package/dist/cli/loop-executor.d.ts +22 -0
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +60 -13
- 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/stats.d.ts +62 -4
- package/dist/cli/stats.d.ts.map +1 -1
- package/dist/cli/stats.js +282 -5
- package/dist/cli/stats.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/cli/ui/index.d.ts +2 -2
- package/dist/cli/ui/index.d.ts.map +1 -1
- package/dist/cli/ui/index.js +1 -1
- package/dist/cli/ui/index.js.map +1 -1
- package/dist/cli/ui/presenter.d.ts +43 -0
- package/dist/cli/ui/presenter.d.ts.map +1 -1
- package/dist/cli/ui/presenter.js +88 -1
- package/dist/cli/ui/presenter.js.map +1 -1
- package/dist/config/manager.d.ts +29 -1
- package/dist/config/manager.d.ts.map +1 -1
- package/dist/config/manager.js +178 -143
- package/dist/config/manager.js.map +1 -1
- package/dist/config/paths.d.ts +48 -1
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +59 -9
- package/dist/config/paths.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/config/service-catalog.js +1 -1
- package/dist/config/service-catalog.js.map +1 -1
- package/dist/config/service-probe.js +1 -1
- package/dist/config/service-probe.js.map +1 -1
- package/dist/config/types.d.ts +22 -0
- package/dist/config/types.d.ts.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 +20 -8
- 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 +149 -5
- package/dist/gateway/registry.js.map +1 -1
- package/dist/learning/auto-router.d.ts +30 -4
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +54 -38
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/cost-classification.d.ts +188 -0
- package/dist/learning/cost-classification.d.ts.map +1 -0
- package/dist/learning/cost-classification.js +294 -0
- package/dist/learning/cost-classification.js.map +1 -0
- package/dist/learning/cost-ledger-maintenance.d.ts +122 -0
- package/dist/learning/cost-ledger-maintenance.d.ts.map +1 -0
- package/dist/learning/cost-ledger-maintenance.js +289 -0
- package/dist/learning/cost-ledger-maintenance.js.map +1 -0
- package/dist/learning/cost-reconcile.d.ts +160 -0
- package/dist/learning/cost-reconcile.d.ts.map +1 -0
- package/dist/learning/cost-reconcile.js +238 -0
- package/dist/learning/cost-reconcile.js.map +1 -0
- package/dist/learning/cost-tracker.d.ts +134 -13
- package/dist/learning/cost-tracker.d.ts.map +1 -1
- package/dist/learning/cost-tracker.js +190 -68
- package/dist/learning/cost-tracker.js.map +1 -1
- package/dist/learning/model-pricing.d.ts +240 -0
- package/dist/learning/model-pricing.d.ts.map +1 -0
- package/dist/learning/model-pricing.js +309 -0
- package/dist/learning/model-pricing.js.map +1 -0
- package/dist/learning/model-selection.d.ts +15 -0
- package/dist/learning/model-selection.d.ts.map +1 -1
- package/dist/learning/model-selection.js +22 -2
- package/dist/learning/model-selection.js.map +1 -1
- package/dist/learning/model-warmup.d.ts.map +1 -1
- package/dist/learning/model-warmup.js +4 -1
- package/dist/learning/model-warmup.js.map +1 -1
- 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/session-store.d.ts +1 -5
- package/dist/learning/session-store.d.ts.map +1 -1
- package/dist/learning/session-store.js +10 -1
- package/dist/learning/session-store.js.map +1 -1
- 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/learning/step-checkpoint.d.ts +32 -3
- package/dist/learning/step-checkpoint.d.ts.map +1 -1
- package/dist/learning/step-checkpoint.js +33 -4
- package/dist/learning/step-checkpoint.js.map +1 -1
- package/dist/mcp/settings.d.ts +15 -3
- package/dist/mcp/settings.d.ts.map +1 -1
- package/dist/mcp/settings.js +30 -15
- package/dist/mcp/settings.js.map +1 -1
- package/dist/parity/drivers.d.ts.map +1 -1
- package/dist/parity/drivers.js +31 -5
- package/dist/parity/drivers.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.d.ts.map +1 -1
- package/dist/tools/child-agent-runtime.js +13 -7
- package/dist/tools/child-agent-runtime.js.map +1 -1
- package/dist/tools/followup-utils.d.ts +36 -0
- package/dist/tools/followup-utils.d.ts.map +1 -1
- package/dist/tools/followup-utils.js +66 -0
- package/dist/tools/followup-utils.js.map +1 -1
- package/dist/tools/modality/browser.js +2 -2
- package/dist/tools/modality/browser.js.map +1 -1
- package/dist/tools/modality/image-gen.d.ts +1 -1
- package/dist/tools/modality/image-gen.js +1 -1
- package/dist/tools/modality/image-providers.js +1 -1
- package/dist/tools/modality/image-providers.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/modality/vision.d.ts +1 -1
- package/dist/tools/modality/vision.js +1 -1
- package/dist/tools/modality/voice.js +2 -2
- package/dist/tools/modality/voice.js.map +1 -1
- package/dist/tools/neutts-synth.d.ts +3 -3
- package/dist/tools/neutts-synth.js +3 -3
- package/dist/tools/neutts-synth.js.map +1 -1
- package/dist/tools/process-liveness.d.ts +50 -0
- package/dist/tools/process-liveness.d.ts.map +1 -1
- package/dist/tools/process-liveness.js +82 -9
- package/dist/tools/process-liveness.js.map +1 -1
- package/dist/tools/registry.d.ts +1 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +102 -4
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +18 -1
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +71 -19
- 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/tools/web-research.d.ts +1 -1
- package/dist/tools/web-research.js +3 -3
- package/dist/tools/web-research.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 +78 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +122 -12
- 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 +7 -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 +326 -23
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +109 -0
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/task-runner.d.ts +17 -1
- package/dist/web-dashboard/task-runner.d.ts.map +1 -1
- package/dist/web-dashboard/task-runner.js +40 -4
- package/dist/web-dashboard/task-runner.js.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-CzRU84ET.css +1 -0
- package/src/web-dashboard/public/assets/index-V0rIqVbg.js +236 -0
- package/src/web-dashboard/public/assets/index-V0rIqVbg.js.map +1 -0
- package/src/web-dashboard/public/index.html +2 -2
- package/src/web-dashboard/public/assets/index-DycGBWwy.js +0 -236
- package/src/web-dashboard/public/assets/index-DycGBWwy.js.map +0 -1
- package/src/web-dashboard/public/assets/index-aO3aG7ex.css +0 -1
package/dist/cli/chat.js
CHANGED
|
@@ -16,7 +16,7 @@ import { decisionsRecordingEnabled, recallDecisionBlock } from '../learning/deci
|
|
|
16
16
|
import { getMemoryManager } from '../memory/manager.js';
|
|
17
17
|
import { logger } from '../utils/logger.js';
|
|
18
18
|
import { printOrchestrationResult } from './execute.js';
|
|
19
|
-
import { AnswerStream, attachShellCards, gitDiffCard, presentAnswer, shouldUseRichUi, toolCallCard, } from './ui/index.js';
|
|
19
|
+
import { AnswerStream, attachShellCards, gitDiffCard, presentAnswer, shouldUseRichUi, THINKING_NOTE, toolCallCard, } from './ui/index.js';
|
|
20
20
|
// WS5 (#27) — isolation (a git worktree around the turn) and resume (replaying
|
|
21
21
|
// recorded steps instead of re-paying for them). See the module headers for why
|
|
22
22
|
// the worktree is created HERE, around the whole turn, and why a replay is
|
|
@@ -36,6 +36,7 @@ import { TaskModelContinuity, taskSignature } from '../learning/task-model-conti
|
|
|
36
36
|
import { describeFailureKind, renderExhaustionReport, } from '../learning/exhaustion-report.js';
|
|
37
37
|
import { estimateTokens } from '../learning/cost-tracker.js';
|
|
38
38
|
import { getModelRegistry } from '../learning/model-registry.js';
|
|
39
|
+
import { getQuotaLedger } from '../learning/quota-ledger.js';
|
|
39
40
|
import { costSince } from '../learning/cost-tracker.js';
|
|
40
41
|
import { refreshModelRegistry } from '../inference/model-probe.js';
|
|
41
42
|
import { startWarmupDaemon } from '../learning/model-warmup.js';
|
|
@@ -74,7 +75,7 @@ import { getTool, TOOL_CONTRACT_JSON } from '../tools/registry.js';
|
|
|
74
75
|
// WS1 — the finding tool's bus event, and the wire shape every surface reports.
|
|
75
76
|
import { FINDING_EVENT } from '../tools/finding-tool.js';
|
|
76
77
|
import { debugLogNotice, sessionDebugLog } from '../observability/debug-log.js';
|
|
77
|
-
import { buildFollowupContinuationPrompt, isSuggestedFollowup, withContinuationFollowups, } from '../tools/followup-utils.js';
|
|
78
|
+
import { buildFollowupContinuationPrompt, followupsFromAnswerList, isSuggestedFollowup, withContinuationFollowups, } from '../tools/followup-utils.js';
|
|
78
79
|
// S2/S3 — the shared tool-call reliability helpers (salvage failed_generation,
|
|
79
80
|
// compact fallback schemas). One copy for every tool-calling surface, not
|
|
80
81
|
// chat-private (execute/plan/… inherit the fix).
|
|
@@ -484,6 +485,44 @@ export const PINNED_SAME_PAIR_GRACE_MS = 120_000;
|
|
|
484
485
|
* these reach the {@link PINNED_SAME_PAIR_GRACE_MS} budget.
|
|
485
486
|
*/
|
|
486
487
|
export const PINNED_GRACE_BACKOFF_MS = [5_000, 15_000, 30_000, 45_000];
|
|
488
|
+
/**
|
|
489
|
+
* The whole pinned-escalation budget, read as CONFIGURATION rather than a build
|
|
490
|
+
* property — the same rule {@link transientRetryDelaysMs} follows, and for the
|
|
491
|
+
* same reason.
|
|
492
|
+
*
|
|
493
|
+
* `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` is the dial every headless surface already
|
|
494
|
+
* sets (the gateway's lifecycle contracts, the parity driver, CI): it says "a
|
|
495
|
+
* flapping provider costs milliseconds here, not minutes". The pinned window is
|
|
496
|
+
* the SAME KIND of wait — a bounded retry of one pair, followed by a question no
|
|
497
|
+
* unattended surface can answer — so it has to honour that dial. Measured
|
|
498
|
+
* 2026-10-15: the dial was in force and four suites still timed out, because the
|
|
499
|
+
* pinned window ignored it — a gateway inbound message sat 95s on a dead pin and
|
|
500
|
+
* then held the turn open for the five-minute answer window.
|
|
501
|
+
*
|
|
502
|
+
* Set (and not nonsense) → the dial IS the pinned schedule: the window is the sum
|
|
503
|
+
* of the waits it declares, and the answer window collapses to the same budget,
|
|
504
|
+
* so a dialed run can never wait longer than it asked for. Unset → the shipped
|
|
505
|
+
* two-minute window and the five-minute answer window stand unchanged (see
|
|
506
|
+
* {@link PINNED_SAME_PAIR_GRACE_MS} / {@link PINNED_ANSWER_TIMEOUT_MS}).
|
|
507
|
+
*/
|
|
508
|
+
export function pinnedGraceWindow(env = process.env) {
|
|
509
|
+
if (!transientRetryScheduleOverridden(env)) {
|
|
510
|
+
return {
|
|
511
|
+
graceMs: PINNED_SAME_PAIR_GRACE_MS,
|
|
512
|
+
backoffMs: PINNED_GRACE_BACKOFF_MS,
|
|
513
|
+
answerTimeoutMs: PINNED_ANSWER_TIMEOUT_MS,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const dialed = transientRetryDelaysMs(env);
|
|
517
|
+
// Floored at 1ms so a `0` dial still buys one retry rather than an empty window
|
|
518
|
+
// (the same attempt the undialed path always makes first).
|
|
519
|
+
const graceMs = Math.max(1, dialed.reduce((sum, ms) => sum + ms, 0));
|
|
520
|
+
return { graceMs, backoffMs: dialed, answerTimeoutMs: graceMs };
|
|
521
|
+
}
|
|
522
|
+
/** A wait as a human reads it — seconds below a minute, minutes above. */
|
|
523
|
+
function describeWait(ms) {
|
|
524
|
+
return ms >= 60_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1000)}s`;
|
|
525
|
+
}
|
|
487
526
|
/**
|
|
488
527
|
* Failure kinds a WAIT can plausibly fix.
|
|
489
528
|
*
|
|
@@ -1314,10 +1353,21 @@ export class ChatCommand extends BaseCommand {
|
|
|
1314
1353
|
* cleared there.
|
|
1315
1354
|
*/
|
|
1316
1355
|
liveAnswer(header, opts = {}) {
|
|
1317
|
-
if (!shouldUseRichUi(opts))
|
|
1318
|
-
return { overrides: {}, stop: () => { }, stream: null };
|
|
1356
|
+
if (!shouldUseRichUi(opts)) {
|
|
1357
|
+
return { overrides: {}, stop: () => { }, start: () => { }, stream: null };
|
|
1358
|
+
}
|
|
1319
1359
|
const stream = new AnswerStream({ header, headerColor: 'assistant' }, opts);
|
|
1320
1360
|
let begun = false;
|
|
1361
|
+
/** Show a note, mounting the frame if this is the first sign of life. */
|
|
1362
|
+
const show = (note) => {
|
|
1363
|
+
if (!begun) {
|
|
1364
|
+
begun = true;
|
|
1365
|
+
stream.begin(note === undefined ? {} : { note });
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
if (note !== undefined)
|
|
1369
|
+
stream.setNote(note);
|
|
1370
|
+
};
|
|
1321
1371
|
// Shell commands are emitted on the process-wide bus (utils/shell.ts owns
|
|
1322
1372
|
// them), so the turn subscribes for its own duration and detaches on stop().
|
|
1323
1373
|
const detachShell = attachShellCards((card) => stream.card(card));
|
|
@@ -1325,25 +1375,30 @@ export class ChatCommand extends BaseCommand {
|
|
|
1325
1375
|
stream,
|
|
1326
1376
|
overrides: {
|
|
1327
1377
|
onToken: (token) => {
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1378
|
+
show(undefined);
|
|
1379
|
+
// The text is the signal now — a stale `thinking…` beside streaming
|
|
1380
|
+
// prose reads as a hang that is not happening.
|
|
1381
|
+
stream.setNote(undefined);
|
|
1332
1382
|
stream.append(token);
|
|
1333
1383
|
},
|
|
1334
1384
|
onToolCall: (phase, info) => {
|
|
1335
1385
|
if (phase === 'started') {
|
|
1336
1386
|
// The step's preamble is not the answer: clear it so the preview
|
|
1337
|
-
// shows the step that is actually producing the reply
|
|
1387
|
+
// shows the step that is actually producing the reply — and name the
|
|
1388
|
+
// call that is running, so the EXECUTION half is visible too.
|
|
1338
1389
|
if (begun)
|
|
1339
1390
|
stream.reset();
|
|
1391
|
+
show(`⚙ ${info.tool} …`);
|
|
1340
1392
|
return;
|
|
1341
1393
|
}
|
|
1394
|
+
if (begun)
|
|
1395
|
+
stream.setNote(undefined);
|
|
1342
1396
|
// Completed call → an archived card (this is what the turn DID).
|
|
1343
1397
|
stream.card(toolCallCard({ tool: info.tool, ok: info.ok, error: info.error, result: info.result, durationMs: info.durationMs, args: info.args }));
|
|
1344
1398
|
},
|
|
1345
1399
|
onGitDiff: (payload) => stream.card(gitDiffCard(payload)),
|
|
1346
1400
|
},
|
|
1401
|
+
start: (note) => show(note ?? THINKING_NOTE),
|
|
1347
1402
|
stop: () => {
|
|
1348
1403
|
detachShell();
|
|
1349
1404
|
stream.finish({ archive: false });
|
|
@@ -1480,6 +1535,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
1480
1535
|
// elsewhere). `stop()` unmounts the preview once the turn returns; the
|
|
1481
1536
|
// final frame is printed by the answer path below.
|
|
1482
1537
|
const live = this.liveAnswer(`nuvira · ${provider.name}${model ? ` / ${model}` : ''}`);
|
|
1538
|
+
// Mount before the turn: the first token can be seconds away on a local or
|
|
1539
|
+
// cold provider, and a silent console is indistinguishable from a hang.
|
|
1540
|
+
live.start();
|
|
1483
1541
|
const answer = await this.runChatAnswer(prompt, [], { type, provider, model }, options || {}, cacheEnabled, { auto: autoMode }, parsed,
|
|
1484
1542
|
// WS5 (#27) — isolation and resume ride on the CLI's own flags. Passed
|
|
1485
1543
|
// per turn: in the REPL each message is its own turn (see the manual).
|
|
@@ -1534,6 +1592,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
1534
1592
|
if (!picked)
|
|
1535
1593
|
break;
|
|
1536
1594
|
const liveNext = this.liveAnswer(`nuvira · ${provider.name}${model ? ` / ${model}` : ''}`);
|
|
1595
|
+
liveNext.start();
|
|
1537
1596
|
const next = await this.runChatAnswer(picked, history, { type, provider, model }, options || {}, cacheEnabled, { auto: autoMode }, parseRequestSync(picked),
|
|
1538
1597
|
// P5 — a picked followup continues the previous execution.
|
|
1539
1598
|
{ continuation: true, ...this.ws5Overrides(options), ...liveNext.overrides }).finally(() => liveNext.stop());
|
|
@@ -1632,6 +1691,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
1632
1691
|
const session = { type, provider, model: effectiveModel };
|
|
1633
1692
|
// P7 — live preview for this turn (see `liveAnswer`).
|
|
1634
1693
|
const live = this.liveAnswer(`nuvira · ${provider.name}${effectiveModel ? ` / ${effectiveModel}` : ''}`);
|
|
1694
|
+
live.start();
|
|
1635
1695
|
const answer = await withLogCorrelation({ sessionId: chatSessionId }, () => recordMetricTime('llm.answer.ms', () => this.runChatAnswer(message, history, session, options || {}, cacheEnabled, { auto: autoMode }, parsed,
|
|
1636
1696
|
// P5 — a picked followup (or a typed one that matches the last
|
|
1637
1697
|
// suggestions) is a continuation, not a fresh independent request.
|
|
@@ -1811,8 +1871,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
1811
1871
|
// is the one fact needed to tell a replay from a real turn.
|
|
1812
1872
|
debugLog?.event('cache.hit', { chars: cached.response.length, scope: turnScope });
|
|
1813
1873
|
const cacheNotice = debugLogNotice(ctxOverrides?.debugSurface ?? 'cli-chat', debugLog?.write() ?? null);
|
|
1874
|
+
// Diagnostic (where a bug report's evidence lives), not part of the
|
|
1875
|
+
// answer: `--debug` only. A normal turn prints the answer and nothing else.
|
|
1814
1876
|
if (cacheNotice)
|
|
1815
|
-
logger.
|
|
1877
|
+
logger.debug(cacheNotice);
|
|
1816
1878
|
return {
|
|
1817
1879
|
content: cached.response,
|
|
1818
1880
|
...(cached.toolCalls ? { toolCalls: cached.toolCalls } : {}),
|
|
@@ -1847,6 +1909,19 @@ export class ChatCommand extends BaseCommand {
|
|
|
1847
1909
|
* in, and what it changed.
|
|
1848
1910
|
*/
|
|
1849
1911
|
const report = ctxOverrides?.onProgress ?? ((line) => void logger.info(line));
|
|
1912
|
+
/**
|
|
1913
|
+
* Where a DIAGNOSTIC notice goes (checkpoint/resume counts — printed on EVERY
|
|
1914
|
+
* `execute` run, since checkpointing is default-on).
|
|
1915
|
+
*
|
|
1916
|
+
* A surface with its own progress channel still gets them — the dashboard
|
|
1917
|
+
* renders the resume notice, and the gateway logs it — but this process's
|
|
1918
|
+
* console only shows them under `--debug` (`-d`, the switch that raises the
|
|
1919
|
+
* logger to the debug channel). Measured: a plain `nuvira execute` question
|
|
1920
|
+
* printed a checkpoint line and a resume-probe line with its why-not detail
|
|
1921
|
+
* before the answer, on EVERY turn. A refusal or an error still goes through
|
|
1922
|
+
* `report` above; those are the user's business.
|
|
1923
|
+
*/
|
|
1924
|
+
const reportDiagnostic = ctxOverrides?.onProgress ?? ((line) => void logger.debug(line));
|
|
1850
1925
|
// Same directory the cache key above is scoped to (see `turnScope`) — one
|
|
1851
1926
|
// resolution per turn, so the cached answer and the work cannot disagree
|
|
1852
1927
|
// about which project the turn belongs to.
|
|
@@ -1903,7 +1978,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
1903
1978
|
// this line used to state the outcome here, which meant every resumed turn
|
|
1904
1979
|
// announced "nothing to replay" before it had tried anything.
|
|
1905
1980
|
if (resume)
|
|
1906
|
-
|
|
1981
|
+
reportDiagnostic(resume.ledger.openNotice());
|
|
1907
1982
|
/**
|
|
1908
1983
|
* Attach this turn's isolation and resume outcomes to whatever it returns.
|
|
1909
1984
|
*
|
|
@@ -1918,6 +1993,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
1918
1993
|
if (worktree) {
|
|
1919
1994
|
const outcome = endIsolation(worktree, { keep: isolationRequest.keep });
|
|
1920
1995
|
extra.worktree = outcome;
|
|
1996
|
+
// The isolation notice is USER-facing even though the checkpoint one
|
|
1997
|
+
// beside it is not: it carries the worktree's path, and for a kept
|
|
1998
|
+
// worktree that path is where the turn's changes are.
|
|
1921
1999
|
report(outcome.notice);
|
|
1922
2000
|
}
|
|
1923
2001
|
if (resume) {
|
|
@@ -1925,7 +2003,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
1925
2003
|
extra.resume = outcome;
|
|
1926
2004
|
// The wording lives in `ResumeOutcome.notice` — one sentence, every
|
|
1927
2005
|
// surface, including the reason when nothing replayed.
|
|
1928
|
-
|
|
2006
|
+
reportDiagnostic(outcome.notice);
|
|
1929
2007
|
}
|
|
1930
2008
|
return { ...result, ...extra };
|
|
1931
2009
|
};
|
|
@@ -1964,7 +2042,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
1964
2042
|
let machineBlock = '';
|
|
1965
2043
|
try {
|
|
1966
2044
|
const { buildMachineFactsBlock } = await import('../learning/machine-facts.js');
|
|
1967
|
-
|
|
2045
|
+
// …plus the optional tools this machine does NOT have, so a feature that
|
|
2046
|
+
// needs one is named as skipped (with its install command) instead of
|
|
2047
|
+
// being reported as done. Empty string when everything is installed.
|
|
2048
|
+
const { buildRuntimeDependenciesBlock } = await import('../learning/runtime-dependencies.js');
|
|
2049
|
+
machineBlock = buildMachineFactsBlock() + buildRuntimeDependenciesBlock();
|
|
1968
2050
|
}
|
|
1969
2051
|
catch {
|
|
1970
2052
|
machineBlock = '';
|
|
@@ -2535,52 +2617,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
2535
2617
|
}
|
|
2536
2618
|
return false;
|
|
2537
2619
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
const pick = sameClassFallback(pinModel, [
|
|
2565
|
-
...(base ? [base] : []),
|
|
2566
|
-
...preferredModelsFor(resolved.type),
|
|
2567
|
-
]);
|
|
2568
|
-
const model = pick?.model ?? base;
|
|
2569
|
-
if (pick?.crossClass) {
|
|
2570
|
-
logger.warn(` ⚠️ ${pinModel ?? 'the pinned model'} is not available anywhere; handing over to ` +
|
|
2571
|
-
`${resolved.type}/${model} (${capabilityClassOf(model)} class — no same-class model was available).`);
|
|
2572
|
-
}
|
|
2573
|
-
if (install({ type: resolved.type, provider: resolved.provider, model }))
|
|
2574
|
-
return true;
|
|
2575
|
-
}
|
|
2576
|
-
catch {
|
|
2577
|
-
// Next fallback candidate.
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
}
|
|
2581
|
-
catch {
|
|
2582
|
-
// Fall through to `false` — the loop's bounded path still applies.
|
|
2583
|
-
}
|
|
2620
|
+
/**
|
|
2621
|
+
* ── A PINNED TURN DOES NOT SUBSTITUTE ON AN UNUSABLE RESPONSE ────────
|
|
2622
|
+
*
|
|
2623
|
+
* This used to walk the configured fallback chain for every non-strict pin
|
|
2624
|
+
* (and refuse only under strict), so the pair the user pinned could be
|
|
2625
|
+
* replaced mid-turn by whatever the health ranking liked. Measured live
|
|
2626
|
+
* (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro` returned an empty
|
|
2627
|
+
* response twice, and the turn was answered by `local/deepseek-coder:latest`
|
|
2628
|
+
* — a weaker model the user never named, on a conversation that was pinned
|
|
2629
|
+
* with its "this model only" lock on but unpersisted (see
|
|
2630
|
+
* `ChatConsole`'s `pinnedStrict`). The switch also arrived as a handoff note
|
|
2631
|
+
* the model wrote to ITSELF; the user's only clue was the answer's wording.
|
|
2632
|
+
*
|
|
2633
|
+
* The pinned rule is now the same one {@link pinnedEscalation} implements for
|
|
2634
|
+
* a thrown failure: nothing is substituted without the user choosing it. An
|
|
2635
|
+
* EMPTY response cannot ask from here — the loop calls this hook but owns the
|
|
2636
|
+
* thread, so this function has no call to retry with — which is why the
|
|
2637
|
+
* caller's `emptyResponsePatienceMs` gives the pinned pair its full grace
|
|
2638
|
+
* window FIRST (the loop re-asks the SAME pair, nothing routed elsewhere), and
|
|
2639
|
+
* what is left when that window is spent is an honest stop, reported by the
|
|
2640
|
+
* surface with its retry offer. Routing a pinned turn to another model behind
|
|
2641
|
+
* the user's back is the defect, not the remedy.
|
|
2642
|
+
*
|
|
2643
|
+
* AUTO turns are untouched above: they are the case where substituting IS the
|
|
2644
|
+
* user's instruction (and the case the walk exists for).
|
|
2645
|
+
*/
|
|
2584
2646
|
return false;
|
|
2585
2647
|
};
|
|
2586
2648
|
try {
|
|
@@ -2641,6 +2703,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
2641
2703
|
provider: session.type,
|
|
2642
2704
|
model: this.lastAttempt?.model ?? session.model ?? 'default',
|
|
2643
2705
|
};
|
|
2706
|
+
const failedEarlierStep = this.turnAttempts.some((a) => a.provider === pair.provider && a.model === pair.model);
|
|
2707
|
+
/**
|
|
2708
|
+
* ── A PINNED PAIR GETS THE PIN'S OWN GRACE WINDOW ─────────────
|
|
2709
|
+
*
|
|
2710
|
+
* An empty 200 is a PROVIDER failure, and for a PIN it is the one
|
|
2711
|
+
* failure the user already told us how to handle: wait on the model
|
|
2712
|
+
* they named. The blip policy's [2s, 5s] is the wrong instrument for
|
|
2713
|
+
* that contract — it is sized for "is this provider having a
|
|
2714
|
+
* moment", not for "you asked for THIS model".
|
|
2715
|
+
*
|
|
2716
|
+
* Measured (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro`
|
|
2717
|
+
* returned nothing, was re-asked after 2s and after 5s, and the turn
|
|
2718
|
+
* was then handed to `groq/openai/gpt-oss-120b` and finally answered
|
|
2719
|
+
* by `local/deepseek-coder:latest` — seven seconds of patience and a
|
|
2720
|
+
* model the user never picked. The strict path had a two-minute
|
|
2721
|
+
* window for this; an empty response could never reach it (the loop
|
|
2722
|
+
* never throws on one), so the pin got the blip schedule instead.
|
|
2723
|
+
*
|
|
2724
|
+
* So the pinned schedule is the SAME one {@link pinnedEscalation}
|
|
2725
|
+
* uses for a thrown failure — nothing is routed anywhere inside it —
|
|
2726
|
+
* and only for the FIRST streak on the pair: once the pair has
|
|
2727
|
+
* already failed a step, waiting again is a pattern, not a blip, and
|
|
2728
|
+
* the loop's own bounded stop follows.
|
|
2729
|
+
*/
|
|
2730
|
+
if (!mode.auto && !failedEarlierStep)
|
|
2731
|
+
return [...pinnedGraceWindow().backoffMs];
|
|
2644
2732
|
return planBlipPatience({
|
|
2645
2733
|
current: pair,
|
|
2646
2734
|
alternatives: failoverAlternatives(this.configManager, pair.provider),
|
|
@@ -2648,7 +2736,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
2648
2736
|
// An entry here means an EARLIER step of this turn already failed
|
|
2649
2737
|
// on this pair — the same pair failing twice is a pattern, not a
|
|
2650
2738
|
// blip, and patience collapses there.
|
|
2651
|
-
failedEarlierStep
|
|
2739
|
+
failedEarlierStep,
|
|
2652
2740
|
deliveredWorkThisTurn,
|
|
2653
2741
|
}).emptyResponseDelaysMs;
|
|
2654
2742
|
}
|
|
@@ -3083,8 +3171,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
3083
3171
|
...(served === requested ? {} : { requested }),
|
|
3084
3172
|
});
|
|
3085
3173
|
const notice = debugLogNotice(ctxOverrides?.debugSurface ?? 'cli-chat', debugLog.write());
|
|
3174
|
+
// `--debug` only — see `reportDiagnostic`: the log path is evidence for a bug
|
|
3175
|
+
// report, not part of the answer.
|
|
3086
3176
|
if (notice)
|
|
3087
|
-
logger.
|
|
3177
|
+
logger.debug(notice);
|
|
3088
3178
|
}
|
|
3089
3179
|
// WS3 (#25) — close the turn span and ship it. The status is the turn's own
|
|
3090
3180
|
// outcome, so a failed turn is a RED span in the collector rather than an
|
|
@@ -3101,7 +3191,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3101
3191
|
});
|
|
3102
3192
|
const otelLine = otelNoticeOnce(ctxOverrides?.debugSurface ?? 'cli-chat');
|
|
3103
3193
|
if (otelLine)
|
|
3104
|
-
logger.
|
|
3194
|
+
logger.debug(otelLine);
|
|
3105
3195
|
await flushSpans();
|
|
3106
3196
|
}
|
|
3107
3197
|
// A4 — an unfinished turn (cancelled, or an outcome that did not conclude)
|
|
@@ -3123,7 +3213,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
3123
3213
|
undeliveredChange: result.undeliveredChange,
|
|
3124
3214
|
unbackedHealthClaim: result.unbackedHealthClaim,
|
|
3125
3215
|
});
|
|
3126
|
-
|
|
3216
|
+
// E3b — what the model SUGGESTED, structurally when it called the tool and
|
|
3217
|
+
// recovered from its own prose list when it did not (see
|
|
3218
|
+
// `followupsFromAnswerList`). Applied HERE, at the one boundary every
|
|
3219
|
+
// surface's followups come from, so the CLI menu, the dashboard chips and
|
|
3220
|
+
// the gateway's "Try next" list agree instead of each recovering its own.
|
|
3221
|
+
const engineFollowups = result.followups && result.followups.length > 0
|
|
3222
|
+
? result.followups
|
|
3223
|
+
: followupsFromAnswerList(result.content);
|
|
3224
|
+
const chatFollowups = withContinuationFollowups(engineFollowups, {
|
|
3127
3225
|
unfinished: !traceOutcomeSucceeded(chatOutcomeForFollowups),
|
|
3128
3226
|
hadTools: (result.successfulToolCalls ?? result.toolCalls ?? []).length > 0,
|
|
3129
3227
|
});
|
|
@@ -3265,44 +3363,81 @@ export class ChatCommand extends BaseCommand {
|
|
|
3265
3363
|
}
|
|
3266
3364
|
}
|
|
3267
3365
|
/**
|
|
3268
|
-
* PINNED
|
|
3269
|
-
*
|
|
3366
|
+
* A PINNED turn whose pair did not answer: wait on the SAME model, then let the
|
|
3367
|
+
* USER decide — never a silent substitution.
|
|
3368
|
+
*
|
|
3369
|
+
* WHY THIS EXISTS. Pinning a model is a quality contract, and the old behaviour
|
|
3370
|
+
* broke it in the one direction the user explicitly forbade: rather than wait, or
|
|
3371
|
+
* say what was happening, a failed pin either dead-ended in "the language model
|
|
3372
|
+
* was unavailable" or was handed to whatever the health ranking liked — including
|
|
3373
|
+
* `local/qwen2.5:0.5b`, a model that cannot call tools at all, so its "answer" was
|
|
3374
|
+
* fabricated rather than merely weaker. Sending that work to a weak model is not a
|
|
3375
|
+
* degraded answer; for an agentic turn it is a wrong one.
|
|
3270
3376
|
*
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
3273
|
-
*
|
|
3274
|
-
*
|
|
3275
|
-
*
|
|
3276
|
-
*
|
|
3277
|
-
*
|
|
3278
|
-
*
|
|
3377
|
+
* WHO IT COVERS. Every pin, not only a strict one. It used to be gated on
|
|
3378
|
+
* `strictModelMode()` alone, which left the default state — the chat's picker pin
|
|
3379
|
+
* with its lock OFF, which is what the client sends for `pinnedModel && !strictPin`
|
|
3380
|
+
* — on the silent path: measured live (trace-1792068695325-42wb1j) `deepseek/
|
|
3381
|
+
* deepseek-v4-pro` returned nothing twice, was handed to `groq/openai/gpt-oss-120b`
|
|
3382
|
+
* and from there to `local/deepseek-coder:latest`, and the turn was answered by a
|
|
3383
|
+
* model the user never named. So the wait-then-ask rule is now the PIN's, and the
|
|
3384
|
+
* STRICT flag only decides what happens when nobody can be asked.
|
|
3279
3385
|
*
|
|
3280
3386
|
* So: the SAME pair is retried inside {@link PINNED_SAME_PAIR_GRACE_MS} first
|
|
3281
3387
|
* (nothing is routed anywhere in that window), and if it still does not answer
|
|
3282
3388
|
* the user is asked — keep trying, switch to the closest SAME-CLASS model the
|
|
3283
|
-
* agent can find and name, type another model/provider
|
|
3284
|
-
*
|
|
3389
|
+
* agent can find and name, or type another model/provider. The question says
|
|
3390
|
+
* outright that nothing has been routed elsewhere.
|
|
3285
3391
|
*
|
|
3286
|
-
*
|
|
3287
|
-
*
|
|
3288
|
-
*
|
|
3289
|
-
*
|
|
3392
|
+
* Outcomes (see {@link PinEscalationOutcome}): a served response when the user's
|
|
3393
|
+
* choice works; `stop` when the pin's own error is the honest outcome; and
|
|
3394
|
+
* `unattended` when NOBODY could be asked on a non-strict pin — the caller then
|
|
3395
|
+
* keeps its documented fallback walk, so a pipeline or worker that cannot answer
|
|
3396
|
+
* a question is not dead-ended by one.
|
|
3290
3397
|
*/
|
|
3291
|
-
async
|
|
3398
|
+
async pinnedEscalation(input) {
|
|
3292
3399
|
const { session } = input;
|
|
3293
3400
|
const pin = `${session.type}/${session.model ?? 'default'}`;
|
|
3294
3401
|
const kind = classifyFallbackError(input.err);
|
|
3402
|
+
// How long this deployment is willing to wait on a pin — the two-minute
|
|
3403
|
+
// default, or the `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` dial when it is set
|
|
3404
|
+
// (see `pinnedGraceWindow`).
|
|
3405
|
+
const { graceMs, backoffMs, answerTimeoutMs } = pinnedGraceWindow();
|
|
3406
|
+
/**
|
|
3407
|
+
* This path cannot serve the pin. STRICT stops on the pin's own error; a
|
|
3408
|
+
* non-strict pin falls through to the caller's fallback walk, which is the
|
|
3409
|
+
* behaviour it had before this rule existed. Consulted only for the cases
|
|
3410
|
+
* where NOBODY has been asked anything — a definitive failure, or a surface
|
|
3411
|
+
* with no one to ask. Once a question has actually been put to the user, the
|
|
3412
|
+
* answer (or their silence) is binding for both modes: substituting after
|
|
3413
|
+
* they chose to stay would be the same silent switch in a new disguise.
|
|
3414
|
+
*/
|
|
3415
|
+
const cannotServe = () => strictModelMode() ? { kind: 'stop' } : { kind: 'unattended' };
|
|
3416
|
+
/**
|
|
3417
|
+
* NOBODY CAN BE ASKED, and the pin is not strict: hand the turn straight back
|
|
3418
|
+
* to the caller's documented fallback walk.
|
|
3419
|
+
*
|
|
3420
|
+
* The grace window below exists to bridge the wait to a QUESTION — "your pin has
|
|
3421
|
+
* not answered, would you rather switch?". With no surface to put that question
|
|
3422
|
+
* to, the window buys nothing but delay: a cron job or queue worker that pinned
|
|
3423
|
+
* a model would stall two minutes on every provider blip and then take the same
|
|
3424
|
+
* walk it could have taken immediately. STRICT is the other case and keeps its
|
|
3425
|
+
* window: "this model or nothing" has no earlier alternative, so waiting on the
|
|
3426
|
+
* named pair is the only thing that contract can do.
|
|
3427
|
+
*/
|
|
3428
|
+
if (!input.askUser && !process.stdin.isTTY && !strictModelMode())
|
|
3429
|
+
return { kind: 'unattended' };
|
|
3295
3430
|
// A DEFINITIVE failure is not a delay (see PINNED_WAITABLE_KINDS) — and a model
|
|
3296
3431
|
// the provider does not serve, or a request-shape fault, is definitive even
|
|
3297
3432
|
// though the classifier can only call it `unknown` (measured: a pin to a
|
|
3298
3433
|
// nonexistent id spent the whole 2-minute grace window on a 400 that could
|
|
3299
3434
|
// never change).
|
|
3300
3435
|
if (!PINNED_WAITABLE_KINDS.has(kind))
|
|
3301
|
-
return
|
|
3436
|
+
return cannotServe();
|
|
3302
3437
|
if (kind === 'unknown') {
|
|
3303
3438
|
const message = input.err instanceof Error ? input.err.message : String(input.err);
|
|
3304
3439
|
if (isHarnessFault(input.err) || PINNED_DEFINITIVE_ERROR_RE.test(message))
|
|
3305
|
-
return
|
|
3440
|
+
return cannotServe();
|
|
3306
3441
|
}
|
|
3307
3442
|
const attemptPin = async () => {
|
|
3308
3443
|
try {
|
|
@@ -3322,30 +3457,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
3322
3457
|
// ── 1. The SAME pair gets its grace window. Nothing is routed anywhere. ──
|
|
3323
3458
|
let served = await attemptPin();
|
|
3324
3459
|
if (served)
|
|
3325
|
-
return served;
|
|
3460
|
+
return { kind: 'served', response: served };
|
|
3326
3461
|
const startedAt = Date.now();
|
|
3327
|
-
for (const backoff of
|
|
3462
|
+
for (const backoff of backoffMs) {
|
|
3328
3463
|
if (input.signal?.aborted)
|
|
3329
|
-
return
|
|
3330
|
-
if (Date.now() - startedAt + backoff >
|
|
3464
|
+
return { kind: 'stop' };
|
|
3465
|
+
if (Date.now() - startedAt + backoff > graceMs)
|
|
3331
3466
|
break;
|
|
3332
3467
|
// Visible on BOTH surfaces: the CLI reads it on the console, the dashboard
|
|
3333
3468
|
// renders the same line as a progress step.
|
|
3334
3469
|
logger.warn(` ⏳ your pinned ${pin} has not answered (${describeFailureKind(kind)}) — retrying it in ` +
|
|
3335
3470
|
`${Math.round(backoff / 1000)}s (${Math.round((Date.now() - startedAt) / 1000)}s of ` +
|
|
3336
|
-
`${Math.round(
|
|
3471
|
+
`${Math.round(graceMs / 1000)}s). Nothing has been sent to another model.`);
|
|
3337
3472
|
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
3338
3473
|
served = await attemptPin();
|
|
3339
3474
|
if (served)
|
|
3340
|
-
return served;
|
|
3475
|
+
return { kind: 'served', response: served };
|
|
3341
3476
|
}
|
|
3342
3477
|
// ── 2. ASK. The pin is the user's contract; only they may release it. ────
|
|
3343
3478
|
const alternatives = this.pinAlternatives(session.model, session.type);
|
|
3344
3479
|
const switchTarget = alternatives.sameClass[0];
|
|
3345
|
-
// Headless (a pipeline, a cron, a queue worker) has no one to ask
|
|
3346
|
-
//
|
|
3480
|
+
// Headless (a pipeline, a cron, a queue worker) has no one to ask. Choosing
|
|
3481
|
+
// silently FOR the user is the defect this fixes, so the pin is never
|
|
3482
|
+
// released here: a strict pin stops on its own error, and a non-strict pin
|
|
3483
|
+
// hands back to the caller's fallback walk — see `cannotServe`.
|
|
3347
3484
|
if (!input.askUser && !process.stdin.isTTY)
|
|
3348
|
-
return
|
|
3485
|
+
return cannotServe();
|
|
3349
3486
|
const ask = input.askUser ?? (await import('../tools/ask-user.js')).renderAskUser;
|
|
3350
3487
|
const choices = [
|
|
3351
3488
|
{
|
|
@@ -3368,11 +3505,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
3368
3505
|
const otherIdx = switchTarget ? 2 : 1;
|
|
3369
3506
|
// NOTE — there is deliberately NO "go to auto" option. It was considered and
|
|
3370
3507
|
// removed: it is the one choice that serves a model the user never named, and
|
|
3371
|
-
// a
|
|
3372
|
-
//
|
|
3373
|
-
//
|
|
3374
|
-
const question = `I have NOT sent this to another model — you pinned ${pin}
|
|
3375
|
-
`${pin} did not answer within ${Math.round(
|
|
3508
|
+
// a pin exists precisely to rule that out. A user who wants routing to decide
|
|
3509
|
+
// clears the pin, which is a decision they own and can see. Nothing in this
|
|
3510
|
+
// method can therefore serve a pair the user did not pick.
|
|
3511
|
+
const question = `I have NOT sent this to another model — you pinned ${pin}, so that choice is yours.\n` +
|
|
3512
|
+
`${pin} did not answer within ${Math.round(graceMs / 1000)}s (${describeFailureKind(kind)}).\n` +
|
|
3376
3513
|
(switchTarget
|
|
3377
3514
|
? `If you would rather switch, ${switchTarget.provider}/${switchTarget.model} is the closest same-class model available.`
|
|
3378
3515
|
: 'No same-class model is available to switch to right now.');
|
|
@@ -3386,7 +3523,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3386
3523
|
const raced = await Promise.race([
|
|
3387
3524
|
ask(question, choices, false).then((a) => ({ answered: true, a })),
|
|
3388
3525
|
new Promise((resolve) => {
|
|
3389
|
-
answerTimer = setTimeout(() => resolve({ answered: false }),
|
|
3526
|
+
answerTimer = setTimeout(() => resolve({ answered: false }), answerTimeoutMs);
|
|
3390
3527
|
// Never hold the process open for an answer that already arrived: an
|
|
3391
3528
|
// un-unref'd 5-minute timer kept the CLI alive long after the turn was
|
|
3392
3529
|
// finished (the `chat` command would appear to hang on exit).
|
|
@@ -3396,14 +3533,14 @@ export class ChatCommand extends BaseCommand {
|
|
|
3396
3533
|
if (answerTimer)
|
|
3397
3534
|
clearTimeout(answerTimer);
|
|
3398
3535
|
if (!raced.answered) {
|
|
3399
|
-
logger.warn(` ⌛ no answer to the pinned-model question after ${
|
|
3536
|
+
logger.warn(` ⌛ no answer to the pinned-model question after ${describeWait(answerTimeoutMs)} — ` +
|
|
3400
3537
|
`stopping on ${pin} rather than choosing for you.`);
|
|
3401
|
-
return
|
|
3538
|
+
return { kind: 'stop' };
|
|
3402
3539
|
}
|
|
3403
3540
|
answer = raced.a;
|
|
3404
3541
|
}
|
|
3405
3542
|
catch {
|
|
3406
|
-
return
|
|
3543
|
+
return { kind: 'stop' };
|
|
3407
3544
|
}
|
|
3408
3545
|
const chosen = typeof answer.index === 'number' ? answer.index : -1;
|
|
3409
3546
|
if (chosen === keepIdx) {
|
|
@@ -3413,18 +3550,22 @@ export class ChatCommand extends BaseCommand {
|
|
|
3413
3550
|
const retryStart = Date.now();
|
|
3414
3551
|
for (;;) {
|
|
3415
3552
|
if (input.signal?.aborted)
|
|
3416
|
-
return
|
|
3553
|
+
return { kind: 'stop' };
|
|
3417
3554
|
const again = await attemptPin();
|
|
3418
3555
|
if (again)
|
|
3419
|
-
return again;
|
|
3420
|
-
if (Date.now() - retryStart >=
|
|
3556
|
+
return { kind: 'served', response: again };
|
|
3557
|
+
if (Date.now() - retryStart >= graceMs)
|
|
3421
3558
|
break;
|
|
3422
|
-
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
3559
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(15_000, graceMs)));
|
|
3423
3560
|
}
|
|
3424
|
-
|
|
3561
|
+
// The user chose to stay on the pin and it still did not answer: the pin's
|
|
3562
|
+
// own failure is the outcome. Substituting now would be the very switch
|
|
3563
|
+
// they just declined.
|
|
3564
|
+
return { kind: 'stop' };
|
|
3425
3565
|
}
|
|
3426
3566
|
if (switchIdx >= 0 && chosen === switchIdx && switchTarget) {
|
|
3427
|
-
|
|
3567
|
+
const switched = await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
|
|
3568
|
+
return switched ? { kind: 'served', response: switched } : { kind: 'stop' };
|
|
3428
3569
|
}
|
|
3429
3570
|
if (chosen === otherIdx) {
|
|
3430
3571
|
const custom = (answer.custom ?? '').trim();
|
|
@@ -3433,12 +3574,12 @@ export class ChatCommand extends BaseCommand {
|
|
|
3433
3574
|
if (pair) {
|
|
3434
3575
|
const servedCustom = await this.serveChosenPair(input, pair.provider, pair.model);
|
|
3435
3576
|
if (servedCustom)
|
|
3436
|
-
return servedCustom;
|
|
3577
|
+
return { kind: 'served', response: servedCustom };
|
|
3437
3578
|
}
|
|
3438
3579
|
logger.warn(` ⚠️ could not use "${custom}" — check that the model is served by that provider.`);
|
|
3439
3580
|
}
|
|
3440
3581
|
}
|
|
3441
|
-
return
|
|
3582
|
+
return { kind: 'stop' };
|
|
3442
3583
|
}
|
|
3443
3584
|
/**
|
|
3444
3585
|
* E3b — the model-call step for the tool loop:
|
|
@@ -3760,7 +3901,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
3760
3901
|
// Logging must never break a turn.
|
|
3761
3902
|
}
|
|
3762
3903
|
try {
|
|
3763
|
-
return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) =>
|
|
3904
|
+
return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) =>
|
|
3905
|
+
// `--debug` only: a retry the user did not have to act on is diagnosis,
|
|
3906
|
+
// not news — and the provider's own error body (a 503 JSON dump) is
|
|
3907
|
+
// quoted as ONE capped line so it can never spray the console.
|
|
3908
|
+
logger.debug(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0].slice(0, 160) : String(err)}`),
|
|
3764
3909
|
// Empty-response patience from the plan: granted for a proven pair
|
|
3765
3910
|
// (registry track record or real work this turn), LONGER when every
|
|
3766
3911
|
// alternative would de-escalate a capability band. A pair with neither
|
|
@@ -3806,6 +3951,33 @@ export class ChatCommand extends BaseCommand {
|
|
|
3806
3951
|
catch {
|
|
3807
3952
|
// Best-effort — bookkeeping must never break the turn.
|
|
3808
3953
|
}
|
|
3954
|
+
// ══ A PINNED TURN ASKS BEFORE IT LEAVES THE PIN ════════════════════
|
|
3955
|
+
//
|
|
3956
|
+
// The rule is the user's, not the mode's: a pin is a quality contract, so
|
|
3957
|
+
// the turn WAITS on the pair it was given and then puts the choice to the
|
|
3958
|
+
// user (keep trying / switch to a named same-class model / type another /
|
|
3959
|
+
// stop). It never slides onto a model they did not name — which is what
|
|
3960
|
+
// the non-strict pin used to do the moment the pair returned an error.
|
|
3961
|
+
//
|
|
3962
|
+
// STRICT keeps its extra meaning: when nobody can be asked (a pipeline, a
|
|
3963
|
+
// worker with no interactive surface) the refusal stands and the pin's own
|
|
3964
|
+
// error is the outcome, rather than a silent substitution. A NON-strict
|
|
3965
|
+
// pin in that position keeps the documented fallback walk below, so an
|
|
3966
|
+
// unattended run cannot be dead-ended by a question it cannot answer.
|
|
3967
|
+
if (!mode.auto) {
|
|
3968
|
+
const decision = await this.pinnedEscalation({
|
|
3969
|
+
session,
|
|
3970
|
+
err,
|
|
3971
|
+
tryGenerate,
|
|
3972
|
+
...(askUser ? { askUser } : {}),
|
|
3973
|
+
signal: abort,
|
|
3974
|
+
});
|
|
3975
|
+
if (decision.kind === 'served')
|
|
3976
|
+
return decision.response;
|
|
3977
|
+
if (decision.kind === 'stop')
|
|
3978
|
+
throw err;
|
|
3979
|
+
// 'unattended' → fall through to the non-auto walk below.
|
|
3980
|
+
}
|
|
3809
3981
|
// Auto mode: fail over across the ranked candidates (never stuck).
|
|
3810
3982
|
if (mode.auto) {
|
|
3811
3983
|
const firstType = session.type;
|
|
@@ -3813,7 +3985,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3813
3985
|
// (the E2E repair-failover contract): one warn per walk, one
|
|
3814
3986
|
// success log per landed candidate — and now the failure KIND in the
|
|
3815
3987
|
// line, so the console says what went wrong instead of only that it did.
|
|
3816
|
-
logger.
|
|
3988
|
+
logger.debug(` ⚠️ ${session.provider.name} failed (${describeFailureKind(primaryKind)}) — trying the next auto candidate...`);
|
|
3817
3989
|
/**
|
|
3818
3990
|
* ── PAIR-SCOPED WALK: follow what is ROUTABLE, not one model per
|
|
3819
3991
|
* provider ────────────────────────────────────────────────────────
|
|
@@ -3841,13 +4013,52 @@ export class ChatCommand extends BaseCommand {
|
|
|
3841
4013
|
const firstPair = `${this.lastAttempt?.provider ?? session.type}|${this.lastAttempt?.model ?? session.model ?? 'default'}`;
|
|
3842
4014
|
const failedPairs = new Set([firstPair]);
|
|
3843
4015
|
const failedProviders = new Set();
|
|
4016
|
+
/**
|
|
4017
|
+
* ── A PROVIDER-WIDE PARK IS EVIDENCE, NOT A HINT ─────────────────
|
|
4018
|
+
*
|
|
4019
|
+
* RULE: every provider the shared bookkeeping has escalated OUT is
|
|
4020
|
+
* left behind by this walk, whether the escalation lives in the
|
|
4021
|
+
* session's own map (this turn's failures) or in the quota ledger
|
|
4022
|
+
* (`parkProvider` — several of the provider's models rate-limited, so
|
|
4023
|
+
* the limit is shared; see `PROVIDER_RATE_LIMIT_ESCALATION_MODELS`).
|
|
4024
|
+
*
|
|
4025
|
+
* THE DEFECT THIS CLOSES (measured, trace-1792068452654-3e1w8d). The
|
|
4026
|
+
* walk read ONLY the session map, so gemini's ledger park — scope
|
|
4027
|
+
* `'provider'`, written at 12:48:23 and good until 16:48 — was
|
|
4028
|
+
* invisible to it. The walk then spent its whole attempt budget
|
|
4029
|
+
* descending gemini's OWN ladder (10 distinct gemini models, all of
|
|
4030
|
+
* them 429ing) and landed on its weakest rung, `gemma-4-26b-a4b-it`,
|
|
4031
|
+
* while `deepseek/deepseek-v4-pro` and `deepseek-flash` sat verified
|
|
4032
|
+
* and unused. One provider's exhaustion must not consume the walk.
|
|
4033
|
+
*
|
|
4034
|
+
* The walk's own rule is unchanged: a PAIR failure keeps the provider
|
|
4035
|
+
* routable (its siblings have their own limits). This only adds the
|
|
4036
|
+
* provider-wide verdict the router's own quota feed already uses, so
|
|
4037
|
+
* "the provider is out" is read from ONE source of truth instead of
|
|
4038
|
+
* two.
|
|
4039
|
+
*/
|
|
4040
|
+
const providerIsOut = (providerType) => {
|
|
4041
|
+
try {
|
|
4042
|
+
if ((this.sessionFailedProviders.get(providerType) ?? 0) > Date.now())
|
|
4043
|
+
return true;
|
|
4044
|
+
}
|
|
4045
|
+
catch {
|
|
4046
|
+
// Unknown session state → fall through to the ledger verdict.
|
|
4047
|
+
}
|
|
4048
|
+
try {
|
|
4049
|
+
return getQuotaLedger().isProviderParked(providerType);
|
|
4050
|
+
}
|
|
4051
|
+
catch {
|
|
4052
|
+
// No ledger → no provider-wide verdict (pair-scoped walking stays).
|
|
4053
|
+
return false;
|
|
4054
|
+
}
|
|
4055
|
+
};
|
|
3844
4056
|
try {
|
|
3845
4057
|
// The bookkeeping already ran for the failure above: a provider still
|
|
3846
4058
|
// carrying an ACTIVE provider-scoped exclusion is out for this walk,
|
|
3847
4059
|
// whatever kind the failure was classified as.
|
|
3848
|
-
if ((
|
|
4060
|
+
if (providerIsOut(session.type))
|
|
3849
4061
|
failedProviders.add(session.type);
|
|
3850
|
-
}
|
|
3851
4062
|
}
|
|
3852
4063
|
catch {
|
|
3853
4064
|
// Best-effort — evidence must never break the walk.
|
|
@@ -3924,7 +4135,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3924
4135
|
// erase the resolved id that tryGenerate just recorded.
|
|
3925
4136
|
if (next.model && next.model !== 'default')
|
|
3926
4137
|
session.model = next.model;
|
|
3927
|
-
logger.
|
|
4138
|
+
logger.debug(`✅ Auto failover: answered from ${next.provider.name} (${next.model}) after ${firstType} failed`);
|
|
3928
4139
|
return resp;
|
|
3929
4140
|
}
|
|
3930
4141
|
catch (nextErr) {
|
|
@@ -3954,17 +4165,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
3954
4165
|
// Is the PROVIDER out, or only this PAIR? The bookkeeping just
|
|
3955
4166
|
// wrote the answer; read it rather than re-deriving the rule, so
|
|
3956
4167
|
// the walk and the exclusion filter can never disagree.
|
|
3957
|
-
|
|
3958
|
-
try {
|
|
3959
|
-
providerOut = (this.sessionFailedProviders.get(next.type) ?? 0) > Date.now();
|
|
3960
|
-
}
|
|
3961
|
-
catch {
|
|
3962
|
-
// Unknown scope → treat it as pair-scoped (the conservative
|
|
3963
|
-
// direction for staying routable).
|
|
3964
|
-
}
|
|
4168
|
+
const providerOut = providerIsOut(next.type);
|
|
3965
4169
|
if (providerOut)
|
|
3966
4170
|
failedProviders.add(next.type);
|
|
3967
|
-
logger.
|
|
4171
|
+
logger.debug(` ⚠️ ${next.type}/${next.model ?? 'default'} failed (${describeFailureKind(nextKind)}) — ` +
|
|
3968
4172
|
(providerOut
|
|
3969
4173
|
? 'that provider is out; trying the next auto candidate...'
|
|
3970
4174
|
: `trying ${next.type}'s next model, then the next provider...`));
|
|
@@ -4184,21 +4388,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
4184
4388
|
.join('; ')}`);
|
|
4185
4389
|
}
|
|
4186
4390
|
}
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
*/
|
|
4192
|
-
const decided = await this.pinnedStrictEscalation({
|
|
4193
|
-
session,
|
|
4194
|
-
err,
|
|
4195
|
-
tryGenerate,
|
|
4196
|
-
...(askUser ? { askUser } : {}),
|
|
4197
|
-
signal: abort,
|
|
4198
|
-
});
|
|
4199
|
-
if (decided)
|
|
4200
|
-
return decided;
|
|
4201
|
-
}
|
|
4391
|
+
// NOTE: the pinned case is handled by `pinnedEscalation` ABOVE, before
|
|
4392
|
+
// this if/else — a strict pin therefore never reaches the non-auto walk
|
|
4393
|
+
// (it throws its own error), which is the behaviour that branch used to
|
|
4394
|
+
// implement on its own.
|
|
4202
4395
|
// Answer-quality resilience: every candidate failed (or none was
|
|
4203
4396
|
// tried) and the error carries the model's raw confused reply —
|
|
4204
4397
|
// deliver THAT instead of failing the whole turn. A confusing answer
|