agent-nuvira 3.3.13 → 3.3.14
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/README.md +67 -1
- package/dist/agents/agents/planner.d.ts.map +1 -1
- package/dist/agents/agents/planner.js +9 -0
- package/dist/agents/agents/planner.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +31 -0
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +74 -126
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/cli/chat.d.ts +89 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +765 -33
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/cli-program.d.ts.map +1 -1
- package/dist/cli/cli-program.js +12 -0
- package/dist/cli/cli-program.js.map +1 -1
- package/dist/cli/config.d.ts +12 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/decisions.d.ts +28 -0
- package/dist/cli/decisions.d.ts.map +1 -0
- package/dist/cli/decisions.js +140 -0
- package/dist/cli/decisions.js.map +1 -0
- package/dist/cli/doctor.d.ts +6 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +46 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/failover-runner.d.ts.map +1 -1
- package/dist/cli/failover-runner.js +19 -1
- package/dist/cli/failover-runner.js.map +1 -1
- package/dist/cli/feedback.d.ts.map +1 -1
- package/dist/cli/feedback.js +5 -1
- package/dist/cli/feedback.js.map +1 -1
- package/dist/cli/hooks.d.ts +30 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +224 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/knowledge.d.ts +9 -1
- package/dist/cli/knowledge.d.ts.map +1 -1
- package/dist/cli/knowledge.js +93 -2
- package/dist/cli/knowledge.js.map +1 -1
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +47 -12
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/model.d.ts +20 -0
- package/dist/cli/model.d.ts.map +1 -1
- package/dist/cli/model.js +294 -14
- package/dist/cli/model.js.map +1 -1
- package/dist/cli/rate.d.ts +78 -0
- package/dist/cli/rate.d.ts.map +1 -0
- package/dist/cli/rate.js +294 -0
- package/dist/cli/rate.js.map +1 -0
- package/dist/config/process-env.d.ts.map +1 -1
- package/dist/config/process-env.js +10 -0
- package/dist/config/process-env.js.map +1 -1
- package/dist/enterprise/vault.d.ts.map +1 -1
- package/dist/enterprise/vault.js +5 -21
- package/dist/enterprise/vault.js.map +1 -1
- package/dist/gateway/hook-contract.d.ts +33 -0
- package/dist/gateway/hook-contract.d.ts.map +1 -1
- package/dist/gateway/hook-contract.js +97 -0
- package/dist/gateway/hook-contract.js.map +1 -1
- package/dist/inference/gemini-adapter.d.ts.map +1 -1
- package/dist/inference/gemini-adapter.js +4 -0
- package/dist/inference/gemini-adapter.js.map +1 -1
- package/dist/inference/interface.d.ts +37 -0
- package/dist/inference/interface.d.ts.map +1 -1
- package/dist/inference/native-tools.d.ts +16 -1
- package/dist/inference/native-tools.d.ts.map +1 -1
- package/dist/inference/native-tools.js +34 -10
- package/dist/inference/native-tools.js.map +1 -1
- package/dist/inference/route-resolver.d.ts +30 -0
- package/dist/inference/route-resolver.d.ts.map +1 -1
- package/dist/inference/route-resolver.js +158 -1
- package/dist/inference/route-resolver.js.map +1 -1
- package/dist/inference/tools.d.ts +16 -1
- package/dist/inference/tools.d.ts.map +1 -1
- package/dist/inference/tools.js +51 -18
- package/dist/inference/tools.js.map +1 -1
- package/dist/learning/acceptance-model.d.ts +247 -0
- package/dist/learning/acceptance-model.d.ts.map +1 -0
- package/dist/learning/acceptance-model.js +708 -0
- package/dist/learning/acceptance-model.js.map +1 -0
- package/dist/learning/auto-router.d.ts +68 -24
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +301 -96
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/autonomy-policy.d.ts +12 -16
- package/dist/learning/autonomy-policy.d.ts.map +1 -1
- package/dist/learning/autonomy-policy.js +50 -48
- package/dist/learning/autonomy-policy.js.map +1 -1
- package/dist/learning/behavioural-labels.d.ts +86 -0
- package/dist/learning/behavioural-labels.d.ts.map +1 -0
- package/dist/learning/behavioural-labels.js +166 -0
- package/dist/learning/behavioural-labels.js.map +1 -0
- package/dist/learning/capability-evidence.d.ts +167 -0
- package/dist/learning/capability-evidence.d.ts.map +1 -0
- package/dist/learning/capability-evidence.js +210 -0
- package/dist/learning/capability-evidence.js.map +1 -0
- package/dist/learning/capability-readiness.d.ts +64 -0
- package/dist/learning/capability-readiness.d.ts.map +1 -0
- package/dist/learning/capability-readiness.js +74 -0
- package/dist/learning/capability-readiness.js.map +1 -0
- package/dist/learning/capability-types.d.ts +169 -0
- package/dist/learning/capability-types.d.ts.map +1 -0
- package/dist/learning/capability-types.js +20 -0
- package/dist/learning/capability-types.js.map +1 -0
- package/dist/learning/catalog-feed.d.ts +89 -0
- package/dist/learning/catalog-feed.d.ts.map +1 -0
- package/dist/learning/catalog-feed.js +211 -0
- package/dist/learning/catalog-feed.js.map +1 -0
- package/dist/learning/cli-intent-effects.d.ts +60 -0
- package/dist/learning/cli-intent-effects.d.ts.map +1 -0
- package/dist/learning/cli-intent-effects.js +153 -0
- package/dist/learning/cli-intent-effects.js.map +1 -0
- package/dist/learning/command-adaptation.d.ts +41 -0
- package/dist/learning/command-adaptation.d.ts.map +1 -0
- package/dist/learning/command-adaptation.js +122 -0
- package/dist/learning/command-adaptation.js.map +1 -0
- package/dist/learning/consent-picture.d.ts +42 -0
- package/dist/learning/consent-picture.d.ts.map +1 -0
- package/dist/learning/consent-picture.js +53 -0
- package/dist/learning/consent-picture.js.map +1 -0
- package/dist/learning/context-budget.d.ts +37 -6
- package/dist/learning/context-budget.d.ts.map +1 -1
- package/dist/learning/context-budget.js +38 -9
- package/dist/learning/context-budget.js.map +1 -1
- package/dist/learning/decision-log.d.ts +114 -0
- package/dist/learning/decision-log.d.ts.map +1 -0
- package/dist/learning/decision-log.js +296 -0
- package/dist/learning/decision-log.js.map +1 -0
- package/dist/learning/deliverable-class.d.ts.map +1 -1
- package/dist/learning/deliverable-class.js +19 -0
- package/dist/learning/deliverable-class.js.map +1 -1
- package/dist/learning/deliverable-corpus.d.ts +117 -0
- package/dist/learning/deliverable-corpus.d.ts.map +1 -0
- package/dist/learning/deliverable-corpus.js +176 -0
- package/dist/learning/deliverable-corpus.js.map +1 -0
- package/dist/learning/error-repair.d.ts +22 -1
- package/dist/learning/error-repair.d.ts.map +1 -1
- package/dist/learning/error-repair.js +37 -0
- package/dist/learning/error-repair.js.map +1 -1
- package/dist/learning/exhaustion-report.d.ts +136 -0
- package/dist/learning/exhaustion-report.d.ts.map +1 -0
- package/dist/learning/exhaustion-report.js +239 -0
- package/dist/learning/exhaustion-report.js.map +1 -0
- package/dist/learning/failure-bookkeeping.d.ts +16 -0
- package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
- package/dist/learning/failure-bookkeeping.js +51 -5
- package/dist/learning/failure-bookkeeping.js.map +1 -1
- package/dist/learning/gate-friction.d.ts +39 -0
- package/dist/learning/gate-friction.d.ts.map +1 -0
- package/dist/learning/gate-friction.js +91 -0
- package/dist/learning/gate-friction.js.map +1 -0
- package/dist/learning/harness-narration.d.ts +34 -0
- package/dist/learning/harness-narration.d.ts.map +1 -0
- package/dist/learning/harness-narration.js +58 -0
- package/dist/learning/harness-narration.js.map +1 -0
- package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
- package/dist/learning/hub-skill-catalog.js +1 -2
- package/dist/learning/hub-skill-catalog.js.map +1 -1
- package/dist/learning/hybrid-router.d.ts +65 -0
- package/dist/learning/hybrid-router.d.ts.map +1 -1
- package/dist/learning/hybrid-router.js +186 -0
- package/dist/learning/hybrid-router.js.map +1 -1
- package/dist/learning/knowledge-base.d.ts +229 -3
- package/dist/learning/knowledge-base.d.ts.map +1 -1
- package/dist/learning/knowledge-base.js +593 -24
- package/dist/learning/knowledge-base.js.map +1 -1
- package/dist/learning/knowledge-eval.d.ts +171 -0
- package/dist/learning/knowledge-eval.d.ts.map +1 -0
- package/dist/learning/knowledge-eval.js +196 -0
- package/dist/learning/knowledge-eval.js.map +1 -0
- package/dist/learning/knowledge-turn.d.ts +92 -0
- package/dist/learning/knowledge-turn.d.ts.map +1 -0
- package/dist/learning/knowledge-turn.js +190 -0
- package/dist/learning/knowledge-turn.js.map +1 -0
- package/dist/learning/learned-commands.d.ts +64 -0
- package/dist/learning/learned-commands.d.ts.map +1 -0
- package/dist/learning/learned-commands.js +108 -0
- package/dist/learning/learned-commands.js.map +1 -0
- package/dist/learning/lexical-search.d.ts +50 -0
- package/dist/learning/lexical-search.d.ts.map +1 -0
- package/dist/learning/lexical-search.js +111 -0
- package/dist/learning/lexical-search.js.map +1 -0
- package/dist/learning/machine-facts.d.ts +71 -0
- package/dist/learning/machine-facts.d.ts.map +1 -0
- package/dist/learning/machine-facts.js +164 -0
- package/dist/learning/machine-facts.js.map +1 -0
- package/dist/learning/markdown-outline.d.ts +73 -0
- package/dist/learning/markdown-outline.d.ts.map +1 -0
- package/dist/learning/markdown-outline.js +136 -0
- package/dist/learning/markdown-outline.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +33 -1
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +123 -9
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-identity.d.ts +80 -0
- package/dist/learning/model-identity.d.ts.map +1 -0
- package/dist/learning/model-identity.js +115 -0
- package/dist/learning/model-identity.js.map +1 -0
- package/dist/learning/model-registry.d.ts +141 -2
- package/dist/learning/model-registry.d.ts.map +1 -1
- package/dist/learning/model-registry.js +324 -38
- package/dist/learning/model-registry.js.map +1 -1
- package/dist/learning/model-scoring.d.ts.map +1 -1
- package/dist/learning/model-scoring.js +8 -1
- package/dist/learning/model-scoring.js.map +1 -1
- package/dist/learning/model-selection.d.ts.map +1 -1
- package/dist/learning/model-selection.js +41 -0
- package/dist/learning/model-selection.js.map +1 -1
- package/dist/learning/outcome-observation.d.ts +49 -0
- package/dist/learning/outcome-observation.d.ts.map +1 -0
- package/dist/learning/outcome-observation.js +95 -0
- package/dist/learning/outcome-observation.js.map +1 -0
- package/dist/learning/pair-entitlement.d.ts +184 -0
- package/dist/learning/pair-entitlement.d.ts.map +1 -0
- package/dist/learning/pair-entitlement.js +214 -0
- package/dist/learning/pair-entitlement.js.map +1 -0
- package/dist/learning/provider-fallback.d.ts +23 -1
- package/dist/learning/provider-fallback.d.ts.map +1 -1
- package/dist/learning/provider-fallback.js +24 -0
- package/dist/learning/provider-fallback.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +65 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +123 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/requirement-probe.d.ts +55 -0
- package/dist/learning/requirement-probe.d.ts.map +1 -0
- package/dist/learning/requirement-probe.js +77 -0
- package/dist/learning/requirement-probe.js.map +1 -0
- package/dist/learning/resilient-call.d.ts +20 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +42 -4
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/response-usability.d.ts +85 -0
- package/dist/learning/response-usability.d.ts.map +1 -0
- package/dist/learning/response-usability.js +135 -0
- package/dist/learning/response-usability.js.map +1 -0
- package/dist/learning/router-bandit.d.ts +132 -0
- package/dist/learning/router-bandit.d.ts.map +1 -1
- package/dist/learning/router-bandit.js +253 -0
- package/dist/learning/router-bandit.js.map +1 -1
- package/dist/learning/routing-history.d.ts +41 -2
- package/dist/learning/routing-history.d.ts.map +1 -1
- package/dist/learning/routing-history.js.map +1 -1
- package/dist/learning/run-trace.d.ts +21 -0
- package/dist/learning/run-trace.d.ts.map +1 -1
- package/dist/learning/run-trace.js +38 -0
- package/dist/learning/run-trace.js.map +1 -1
- package/dist/learning/session-grant.d.ts +75 -0
- package/dist/learning/session-grant.d.ts.map +1 -0
- package/dist/learning/session-grant.js +112 -0
- package/dist/learning/session-grant.js.map +1 -0
- package/dist/learning/task-model-continuity.d.ts +73 -0
- package/dist/learning/task-model-continuity.d.ts.map +1 -0
- package/dist/learning/task-model-continuity.js +87 -0
- package/dist/learning/task-model-continuity.js.map +1 -0
- package/dist/learning/turn-feedback.d.ts +93 -0
- package/dist/learning/turn-feedback.d.ts.map +1 -0
- package/dist/learning/turn-feedback.js +127 -0
- package/dist/learning/turn-feedback.js.map +1 -0
- package/dist/learning/turn-report.d.ts +104 -2
- package/dist/learning/turn-report.d.ts.map +1 -1
- package/dist/learning/turn-report.js +161 -6
- package/dist/learning/turn-report.js.map +1 -1
- package/dist/learning/verdict-routing.d.ts +51 -0
- package/dist/learning/verdict-routing.d.ts.map +1 -0
- package/dist/learning/verdict-routing.js +122 -0
- package/dist/learning/verdict-routing.js.map +1 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +5 -0
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/manager.d.ts +10 -0
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +20 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/mcp-discovery.d.ts +34 -0
- package/dist/mcp/mcp-discovery.d.ts.map +1 -0
- package/dist/mcp/mcp-discovery.js +71 -0
- package/dist/mcp/mcp-discovery.js.map +1 -0
- package/dist/mcp/mcp-schema-cache.d.ts +17 -0
- package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
- package/dist/mcp/mcp-schema-cache.js +47 -3
- package/dist/mcp/mcp-schema-cache.js.map +1 -1
- package/dist/mcp/types.d.ts +28 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js.map +1 -1
- package/dist/tools/ask-user.d.ts.map +1 -1
- package/dist/tools/ask-user.js +4 -0
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/capability-registry.d.ts +150 -0
- package/dist/tools/capability-registry.d.ts.map +1 -0
- package/dist/tools/capability-registry.js +400 -0
- package/dist/tools/capability-registry.js.map +1 -0
- package/dist/tools/child-agent-runtime.d.ts.map +1 -1
- package/dist/tools/child-agent-runtime.js +19 -5
- package/dist/tools/child-agent-runtime.js.map +1 -1
- package/dist/tools/coding-tools.d.ts +17 -5
- package/dist/tools/coding-tools.d.ts.map +1 -1
- package/dist/tools/coding-tools.js +80 -11
- package/dist/tools/coding-tools.js.map +1 -1
- package/dist/tools/command-memo.d.ts +116 -0
- package/dist/tools/command-memo.d.ts.map +1 -0
- package/dist/tools/command-memo.js +202 -0
- package/dist/tools/command-memo.js.map +1 -0
- package/dist/tools/git-tool.d.ts.map +1 -1
- package/dist/tools/git-tool.js +13 -3
- package/dist/tools/git-tool.js.map +1 -1
- package/dist/tools/harness-directive.d.ts +42 -0
- package/dist/tools/harness-directive.d.ts.map +1 -0
- package/dist/tools/harness-directive.js +45 -0
- package/dist/tools/harness-directive.js.map +1 -0
- package/dist/tools/loop-skill-hint.d.ts +74 -174
- package/dist/tools/loop-skill-hint.d.ts.map +1 -1
- package/dist/tools/loop-skill-hint.js +110 -536
- package/dist/tools/loop-skill-hint.js.map +1 -1
- package/dist/tools/mcp-client-tool.d.ts +2 -0
- package/dist/tools/mcp-client-tool.d.ts.map +1 -1
- package/dist/tools/mcp-client-tool.js +3 -0
- package/dist/tools/mcp-client-tool.js.map +1 -1
- package/dist/tools/modality/shared.d.ts +1 -4
- package/dist/tools/modality/shared.d.ts.map +1 -1
- package/dist/tools/modality/shared.js +7 -22
- package/dist/tools/modality/shared.js.map +1 -1
- package/dist/tools/plan-store.d.ts +20 -2
- package/dist/tools/plan-store.d.ts.map +1 -1
- package/dist/tools/plan-store.js +51 -7
- package/dist/tools/plan-store.js.map +1 -1
- package/dist/tools/registry.d.ts +76 -3
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +486 -26
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/remediation-ladder.d.ts.map +1 -1
- package/dist/tools/remediation-ladder.js +8 -0
- package/dist/tools/remediation-ladder.js.map +1 -1
- package/dist/tools/run-cli.d.ts.map +1 -1
- package/dist/tools/run-cli.js +29 -4
- package/dist/tools/run-cli.js.map +1 -1
- package/dist/tools/run-terminal.d.ts +11 -0
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +265 -10
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +321 -13
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +867 -66
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/utils/atomic-store.d.ts +53 -0
- package/dist/utils/atomic-store.d.ts.map +1 -0
- package/dist/utils/atomic-store.js +178 -0
- package/dist/utils/atomic-store.js.map +1 -0
- package/dist/utils/binary-probe.d.ts +25 -0
- package/dist/utils/binary-probe.d.ts.map +1 -0
- package/dist/utils/binary-probe.js +48 -0
- package/dist/utils/binary-probe.js.map +1 -0
- package/dist/utils/workspace-path.d.ts +50 -0
- package/dist/utils/workspace-path.d.ts.map +1 -0
- package/dist/utils/workspace-path.js +86 -0
- package/dist/utils/workspace-path.js.map +1 -0
- package/dist/web-dashboard/chat-console.d.ts +24 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +38 -0
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/project-context.d.ts +27 -2
- package/dist/web-dashboard/project-context.d.ts.map +1 -1
- package/dist/web-dashboard/project-context.js +32 -2
- package/dist/web-dashboard/project-context.js.map +1 -1
- package/dist/web-dashboard/server.d.ts +11 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +362 -11
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +170 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.d.ts +11 -2
- package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.js +24 -12
- package/dist/web-dashboard/workspace-guard.js.map +1 -1
- package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
- package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
- package/dist/web-dashboard/workspace-resolution.js +105 -0
- package/dist/web-dashboard/workspace-resolution.js.map +1 -0
- package/package.json +1 -1
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +0 -1
package/dist/cli/chat.js
CHANGED
|
@@ -5,13 +5,14 @@ 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 } from '../inference/route-resolver.js';
|
|
8
|
+
import { resolveRoute, strictModelMode, strictPinRefusal } 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';
|
|
12
12
|
import { assembleContext, retrievalOptionsFromConfig, recordRetrievalStats } from '../learning/retrieval.js';
|
|
13
13
|
import { getChatHistory } from '../context/history.js';
|
|
14
14
|
import { maybeAutoRecall, recallContextBlock } from '../context/session-recall.js';
|
|
15
|
+
import { decisionsRecordingEnabled, recallDecisionBlock } from '../learning/decision-log.js';
|
|
15
16
|
import { getMemoryManager } from '../memory/manager.js';
|
|
16
17
|
import { logger } from '../utils/logger.js';
|
|
17
18
|
import { printOrchestrationResult } from './execute.js';
|
|
@@ -24,12 +25,17 @@ import { closeResume, openResume, resolveResumeRequest, } from '../learning/step
|
|
|
24
25
|
import { applyActiveModel } from './model.js';
|
|
25
26
|
import { capabilityReasoningEffort, isMaxCapability } from '../config/capability-mode.js';
|
|
26
27
|
import { getProviderFallback, classifyFallbackError, isRetryableError, isTransientForRetry, recordRegistrySuccess } from '../learning/provider-fallback.js';
|
|
27
|
-
import { recordActionFailure } from '../learning/failure-bookkeeping.js';
|
|
28
|
+
import { recordActionFailure, } from '../learning/failure-bookkeeping.js';
|
|
29
|
+
import { getRouterBandit } from '../learning/router-bandit.js';
|
|
30
|
+
import { UnusableModelResponseError } from '../learning/response-usability.js';
|
|
28
31
|
import { resolveThreadBudgetChars } from '../learning/context-budget.js';
|
|
29
32
|
import { getAutoRouter, isAgentAutoRoute, isAutoModel, isAutoProvider, governanceVerdict, adminBudgetVerdict } from '../learning/auto-router.js';
|
|
30
33
|
import { continuationSoftwareText } from '../learning/continuation-intent.js';
|
|
34
|
+
import { TaskModelContinuity, taskSignature } from '../learning/task-model-continuity.js';
|
|
35
|
+
import { describeFailureKind, renderExhaustionReport, } from '../learning/exhaustion-report.js';
|
|
31
36
|
import { estimateTokens } from '../learning/cost-tracker.js';
|
|
32
37
|
import { getModelRegistry } from '../learning/model-registry.js';
|
|
38
|
+
import { costSince } from '../learning/cost-tracker.js';
|
|
33
39
|
import { refreshModelRegistry } from '../inference/model-probe.js';
|
|
34
40
|
import { startWarmupDaemon } from '../learning/model-warmup.js';
|
|
35
41
|
import { recordRoutingDecision } from '../learning/routing-history.js';
|
|
@@ -47,12 +53,15 @@ import { runToolLoop, extractFallbackToolCalls } from '../tools/tool-loop.js';
|
|
|
47
53
|
import { flushSpans, otelNoticeOnce, startTurnSpan } from '../observability/otel.js';
|
|
48
54
|
import { detectAnswerQualityFailure, answerQualityError, toUserFacingGenerationError, isToolCallingUnsupported, stripToolCallArtifacts, } from '../inference/tool-call-utils.js';
|
|
49
55
|
import { beginTrace, endTrace, recordStep, recordTraceEvent, recordTraceFindings, recordTurnReport, buildTraceOutcome, traceOutcomeSucceeded } from '../learning/reasoning-trace.js';
|
|
50
|
-
import { recordWorkingState, getWorkingState, formatWorkingState, isProjectLedgerDir } from '../learning/working-state.js';
|
|
56
|
+
import { recordWorkingState, getWorkingState, formatWorkingState, isProjectLedgerDir, detectRegressionSignal } from '../learning/working-state.js';
|
|
57
|
+
import { recordDeliverableCandidate, markLastDeliverableRejected } from '../learning/deliverable-corpus.js';
|
|
58
|
+
import { deriveAndApplyBehaviouralLabel } from '../learning/behavioural-labels.js';
|
|
51
59
|
import { getLoopExposureMode } from '../tools/toolsets.js';
|
|
52
60
|
import { resolveModelHarnessProfile, shouldSkipNativeTools, isAgenticCapableModel } from '../learning/model-harness.js';
|
|
53
61
|
import { assertAgenticRoute, setWeakModelConsent, resolveWeakModelPolicy, weakRouteNotice } from '../learning/agentic-route-gate.js';
|
|
54
62
|
import { resolvePromptBudget, measurePromptBudget, formatPromptBudgetBreakdown } from '../learning/prompt-budget.js';
|
|
55
63
|
import { buildTurnReport, formatTurnReport } from '../learning/turn-report.js';
|
|
64
|
+
import { turnOutcomeObservation } from '../learning/outcome-observation.js';
|
|
56
65
|
import { resolveAdapterDefault, hasCredentials } from '../learning/model-selection.js';
|
|
57
66
|
import { buildLoopProjectContext } from '../tools/loop-project-context.js';
|
|
58
67
|
import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
|
|
@@ -360,7 +369,10 @@ export function turnCarriesHonestyFlag(result) {
|
|
|
360
369
|
result.unverifiedBuildClaim ||
|
|
361
370
|
result.unverifiedEdit ||
|
|
362
371
|
result.unverifiedEditClaim ||
|
|
363
|
-
result.noActionTaken
|
|
372
|
+
result.noActionTaken ||
|
|
373
|
+
result.artifactIncomplete ||
|
|
374
|
+
result.unverifiedFileClaim ||
|
|
375
|
+
result.artifactShortfall);
|
|
364
376
|
}
|
|
365
377
|
// ─── ChatCommand ────────────────────────────────────────────────────────────
|
|
366
378
|
export class ChatCommand extends BaseCommand {
|
|
@@ -391,6 +403,40 @@ export class ChatCommand extends BaseCommand {
|
|
|
391
403
|
* rate-limited (a genuinely shared limit).
|
|
392
404
|
*/
|
|
393
405
|
sessionFailedModels = new Map();
|
|
406
|
+
/**
|
|
407
|
+
* P3 (fix_model_routing) — task-level model CONTINUITY.
|
|
408
|
+
*
|
|
409
|
+
* The pair that produced usable work for a task is remembered per task
|
|
410
|
+
* signature, and the next turn of the SAME task (a "resume"/"continue" whose
|
|
411
|
+
* routing text resolves to the same ask) offers it FIRST. Routing re-decides
|
|
412
|
+
* every turn, and a signal-free continuation re-decided from scratch is how a
|
|
413
|
+
* working run degraded onto a model that then answered with nothing.
|
|
414
|
+
*
|
|
415
|
+
* A preference, never a pin: the recalled pair goes through the SAME
|
|
416
|
+
* credential/exclusion/registry gates as any other candidate, and the normal
|
|
417
|
+
* pool stands behind it — so a model that has since died costs one check.
|
|
418
|
+
*/
|
|
419
|
+
taskContinuity = new TaskModelContinuity();
|
|
420
|
+
/** The task signature of the turn being routed (see routeMessageAuto). */
|
|
421
|
+
lastTaskSignature;
|
|
422
|
+
/**
|
|
423
|
+
* P4 (fix_model_routing) — what THIS turn actually tried, and why each
|
|
424
|
+
* candidate did not answer. Reset per turn; consumed by the exhaustion report
|
|
425
|
+
* when a turn ends without an answer. Chat's own failover walk is the
|
|
426
|
+
* authority here (the shared walk in `resilient-call.ts` keeps its own log for
|
|
427
|
+
* the gateway/orchestrator paths), so the report can name the models the user
|
|
428
|
+
* was actually served by — not a pool that was considered.
|
|
429
|
+
*/
|
|
430
|
+
turnAttempts = [];
|
|
431
|
+
/** Record one failed/skipped attempt for this turn's exhaustion report. */
|
|
432
|
+
recordTurnAttempt(attempt) {
|
|
433
|
+
try {
|
|
434
|
+
this.turnAttempts.push(attempt);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// Best-effort — telemetry must never break a turn.
|
|
438
|
+
}
|
|
439
|
+
}
|
|
394
440
|
// RATE_LIMIT_EXCLUSION_MS + TRANSIENT_FAILURE_EXCLUSION_MS now live in
|
|
395
441
|
// src/learning/failure-bookkeeping.ts (shared with every action) — see
|
|
396
442
|
// recordActionFailure. Behavior is identical: same values, same semantics.
|
|
@@ -489,6 +535,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
489
535
|
// B — captured from the route so the post-route capability gate below can
|
|
490
536
|
// judge the FINAL pair without re-resolving anything.
|
|
491
537
|
let routeVerdict;
|
|
538
|
+
// C6 — the turn's economy window, taken before the FIRST provider call. The
|
|
539
|
+
// turn report states what THIS turn cost from the persisted ledger; a
|
|
540
|
+
// continuation or a resumed turn runs in a fresh process, so a session
|
|
541
|
+
// counter would report zero for it (`costSince` reads timestamps instead).
|
|
542
|
+
const turnCostSince = Date.now();
|
|
492
543
|
let routedText;
|
|
493
544
|
if (autoMode) {
|
|
494
545
|
// Intent-aware escalation: a bare "yes"/"do it" continuing software work
|
|
@@ -609,9 +660,27 @@ export class ChatCommand extends BaseCommand {
|
|
|
609
660
|
recallBlock = undefined;
|
|
610
661
|
}
|
|
611
662
|
}
|
|
663
|
+
// Decision recall — the SAME attached project's recorded decisions
|
|
664
|
+
// (`.nuvira/decisions.jsonl`), when this ask actually mentions one. It is
|
|
665
|
+
// ADVISORY: it is injected as context and NEVER suppresses an `ask_user`, so
|
|
666
|
+
// a situation that has changed can still ask. Best-effort: an empty log (or
|
|
667
|
+
// no matching token) injects nothing, and a read failure never breaks the
|
|
668
|
+
// turn. Gated by `decisionsRecordingEnabled()` so a test-run turn is inert
|
|
669
|
+
// unless it explicitly opts in (NUVIRA_DECISION_LOG=on).
|
|
670
|
+
let decisionBlock;
|
|
671
|
+
if (opts.projectPath && decisionsRecordingEnabled()) {
|
|
672
|
+
try {
|
|
673
|
+
const block = recallDecisionBlock(opts.projectPath, message, 3);
|
|
674
|
+
if (block)
|
|
675
|
+
decisionBlock = block;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
decisionBlock = undefined;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
612
681
|
const parsed = parseRequestSync(message);
|
|
613
682
|
const dispatchDecision = resolvePipelineDispatch(parsed, { dev: opts.dev, text: message });
|
|
614
|
-
const answer = await this.runChatAnswer(message, opts.history ?? [], { type, provider, model }, { provider: mergedOpts.provider, model: mergedOpts.model, dev: mergedOpts.dev, cache: true }, true, { auto: autoMode }, parsed, { askUser: opts.askUser, onProgress: opts.onProgress, onToolCall: opts.onToolCall, onPlanChange: opts.onPlanChange, onGitDiff: opts.onGitDiff, onSkillDraft: opts.onSkillDraft, onFinding: opts.onFinding, planStore: opts.planStore ?? this.planStore, gateway: opts.gateway, projectContext: opts.projectContext, recallContext: recallBlock, projectPath: opts.projectPath, onToken: opts.onToken, signal: opts.signal, continuation: opts.continuation, systemPolicy: opts.systemPolicy, debugSurface: opts.debugSurface ?? 'cli-chat', debugSession: opts.debugSession, worktree: opts.worktree, keepWorktree: opts.keepWorktree, resume: opts.resume });
|
|
683
|
+
const answer = await this.runChatAnswer(message, opts.history ?? [], { type, provider, model }, { provider: mergedOpts.provider, model: mergedOpts.model, dev: mergedOpts.dev, cache: true }, true, { auto: autoMode }, parsed, { askUser: opts.askUser, onProgress: opts.onProgress, onToolCall: opts.onToolCall, onPlanChange: opts.onPlanChange, onGitDiff: opts.onGitDiff, onSkillDraft: opts.onSkillDraft, onFinding: opts.onFinding, planStore: opts.planStore ?? this.planStore, gateway: opts.gateway, projectContext: opts.projectContext, recallContext: recallBlock, decisionContext: decisionBlock, projectPath: opts.projectPath, onToken: opts.onToken, signal: opts.signal, continuation: opts.continuation, systemPolicy: opts.systemPolicy, debugSurface: opts.debugSurface ?? 'cli-chat', debugSession: opts.debugSession, worktree: opts.worktree, keepWorktree: opts.keepWorktree, resume: opts.resume });
|
|
615
684
|
// No-model fallback: the tool loop could not generate a single response
|
|
616
685
|
// AND the rules assessed a high-confidence pipeline intent — run the
|
|
617
686
|
// pipeline directly (rules decide only when the model is unavailable; the
|
|
@@ -622,6 +691,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
622
691
|
// dispatching it here would run the ask on another engine entirely — the one
|
|
623
692
|
// path that can run it UNISOLATED while the caller asked for isolation.
|
|
624
693
|
if (answer.generationFailed && !answer.refused && dispatchDecision.dispatch && !dispatchDecision.needConfirm) {
|
|
694
|
+
// E2 — this CHANGES THE EXECUTION MODEL: the ask leaves the single chat tool
|
|
695
|
+
// loop and runs as a multi-agent pipeline. Falling back is legitimate
|
|
696
|
+
// (nothing was generated), but it must never be silent. A pinned ask that
|
|
697
|
+
// flips engines without saying so is the same defect as an unattended
|
|
698
|
+
// auto-pick reported as the user's own choice: the user is owed the reason
|
|
699
|
+
// and the fact that their pin is the pair being carried over.
|
|
700
|
+
opts.onProgress?.(` ⤵️ the chat turn produced no answer on ${provider.name}${model ? ` / ${model}` : ''} — ` +
|
|
701
|
+
`running this ask in the multi-agent pipeline instead (a different execution model` +
|
|
702
|
+
`${autoMode ? '' : ', on the same pinned pair'})`);
|
|
625
703
|
const r = await runPipelineTool(message, this.configManager, { provider: type, model, board: false });
|
|
626
704
|
// `success`, not `error`: a pipeline that RAN and failed reports its
|
|
627
705
|
// outcome in `summary` and only sometimes sets `error`, so keying off
|
|
@@ -667,6 +745,16 @@ export class ChatCommand extends BaseCommand {
|
|
|
667
745
|
successfulToolCalls: answer.successfulToolCalls,
|
|
668
746
|
mutations: answer.runTrace?.mutations,
|
|
669
747
|
changedPaths: answer.runTrace?.paths,
|
|
748
|
+
// E1 — decisions the harness took on the user's behalf (an unattended
|
|
749
|
+
// ask_user default), so the report discloses them instead of letting the
|
|
750
|
+
// answer read as a choice the user made.
|
|
751
|
+
assumptions: answer.runTrace?.assumptions,
|
|
752
|
+
// C6 — what the turn cost, from the ledger, so an expensive turn is visible
|
|
753
|
+
// where the turn is described instead of only in a session total.
|
|
754
|
+
cost: (() => {
|
|
755
|
+
const spend = costSince(turnCostSince);
|
|
756
|
+
return { usd: spend.costUsd, tokens: spend.tokens, calls: spend.requests };
|
|
757
|
+
})(),
|
|
670
758
|
flags: {
|
|
671
759
|
unverifiedActionClaim: answer.unverifiedActionClaim,
|
|
672
760
|
unverifiedEdit: answer.unverifiedEdit,
|
|
@@ -675,6 +763,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
675
763
|
undeliveredArtifact: answer.undeliveredArtifact,
|
|
676
764
|
unfulfilledPromise: answer.unfulfilledPromise,
|
|
677
765
|
noActionTaken: answer.noActionTaken,
|
|
766
|
+
// Bundle 19 — the artifact's own admission travels to the report, so the
|
|
767
|
+
// turn cannot read as finished work on any surface that shows it.
|
|
768
|
+
incompleteArtifactClaim: Boolean(answer.artifactIncomplete),
|
|
769
|
+
// Bundle 20 — a fabricated "I wrote the file" reaches the report for the
|
|
770
|
+
// same reason: no surface may read such a turn as finished work.
|
|
771
|
+
unverifiedFileClaim: Boolean(answer.unverifiedFileClaim),
|
|
772
|
+
// Bundle 23 — a file far short of the stated magnitude is the same:
|
|
773
|
+
// the delivered artifact is not what was asked for.
|
|
774
|
+
artifactShortfall: Boolean(answer.artifactShortfall),
|
|
678
775
|
},
|
|
679
776
|
});
|
|
680
777
|
}
|
|
@@ -685,6 +782,109 @@ export class ChatCommand extends BaseCommand {
|
|
|
685
782
|
// reviewable after the fact (the Trace tab renders it), not only in this
|
|
686
783
|
// turn's return value. Best-effort: a trace write never breaks a turn.
|
|
687
784
|
recordTurnReport(answer.traceId, turnReport);
|
|
785
|
+
// The user's message for THIS turn is also a verdict on the PREVIOUS one
|
|
786
|
+
// ("still broken", "no change", or a near-identical re-ask). It is resolved
|
|
787
|
+
// BEFORE this turn's own delivery is recorded, because these signals judge the
|
|
788
|
+
// PREVIOUS turn — run after the record below, they would label the row this very
|
|
789
|
+
// turn just created. Derived ONCE here, because two independent consumers need
|
|
790
|
+
// the same answer: the bandit's `userAccepted` and the deliverable corpus's
|
|
791
|
+
// label. Neither trusts the model's own account of itself.
|
|
792
|
+
const userReportsRegression = detectRegressionSignal(message);
|
|
793
|
+
if (userReportsRegression) {
|
|
794
|
+
// Label the previous authored delivery as a miss. Independent of routing:
|
|
795
|
+
// the corpus is collected whether or not the bandit is on.
|
|
796
|
+
try {
|
|
797
|
+
markLastDeliverableRejected();
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// Collection is best-effort.
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Tier 3 — the verdict the harness can infer from BEHAVIOUR, when the user did
|
|
804
|
+
// not report a regression: a re-ask or a hand-edited artifact is a rejection, an
|
|
805
|
+
// untouched artifact the user then REFERENCES is a (weak) acceptance. Recorded
|
|
806
|
+
// `source: 'derived'` so a fit can weigh it below an explicit verdict. Stands
|
|
807
|
+
// aside when a regression was reported — that path already owns the label.
|
|
808
|
+
if (!userReportsRegression) {
|
|
809
|
+
try {
|
|
810
|
+
deriveAndApplyBehaviouralLabel({ newAsk: message, reportsRegression: false });
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// Collection is best-effort.
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// Item 13 (round 3) — COLLECT, never judge. An authored artifact that met the
|
|
817
|
+
// ask's stated magnitude and did not admit its own omission is the "fluent,
|
|
818
|
+
// on-length" delivery no rule can score; the corpus records it so that a
|
|
819
|
+
// LATER correction can label it. This is a dataset, not a detector: nothing
|
|
820
|
+
// reads it, and no score is derived from it. Recorded LAST so the verdict-on-
|
|
821
|
+
// previous above never sees this turn's own row.
|
|
822
|
+
if (answer.authoredDeliverable) {
|
|
823
|
+
try {
|
|
824
|
+
recordDeliverableCandidate({
|
|
825
|
+
ask: message,
|
|
826
|
+
path: answer.authoredDeliverable.path,
|
|
827
|
+
deliveredWords: answer.authoredDeliverable.deliveredWords,
|
|
828
|
+
...(answer.authoredDeliverable.targetWords !== undefined
|
|
829
|
+
? { targetWords: answer.authoredDeliverable.targetWords }
|
|
830
|
+
: {}),
|
|
831
|
+
...(turnReport?.verification ? { verification: turnReport.verification } : {}),
|
|
832
|
+
// The trace link is what lets an EXPLICIT verdict (`nuvira rate`) label
|
|
833
|
+
// THIS delivery rather than "the most recent one".
|
|
834
|
+
...(answer.traceId ? { traceId: answer.traceId } : {}),
|
|
835
|
+
excerpt: answer.authoredDeliverable.excerpt,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
// Collection is best-effort — never break a turn on a corpus write.
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Feed the MEASURED outcome back into the bandit on the CHAT path too. The
|
|
843
|
+
// orchestrator has done this for pipeline tasks all along; the chat turn —
|
|
844
|
+
// the product's most common entry point — never did, so every turn's real
|
|
845
|
+
// result was thrown away and the router learned from only one of its two
|
|
846
|
+
// execution models. The signal is the turn report's derived VERIFICATION
|
|
847
|
+
// verdict, never the model's own account of itself.
|
|
848
|
+
//
|
|
849
|
+
// Guarded exactly like the orchestrator: only when this turn was AUTO-routed
|
|
850
|
+
// (otherwise `getLastProvider` could reward a stale provider noted by an
|
|
851
|
+
// earlier auto run in this process) and only when bandit learning is on.
|
|
852
|
+
if (autoMode && this.configManager.getAll().routing?.bandit !== false) {
|
|
853
|
+
// `userAccepted: false` for the turn that just ran, applied BEFORE this
|
|
854
|
+
// turn's own outcome is recorded (recording overwrites the attribution the
|
|
855
|
+
// correction refers to). It is walked up to the previous AUTO-routed turn
|
|
856
|
+
// only — `recordUserRejection` no-ops when nothing is pending.
|
|
857
|
+
if (userReportsRegression) {
|
|
858
|
+
try {
|
|
859
|
+
getAutoRouter().recordUserRejection('chat');
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// Learning is best-effort — never break a turn on a bandit error.
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const observation = turnOutcomeObservation(turnReport);
|
|
866
|
+
if (observation) {
|
|
867
|
+
try {
|
|
868
|
+
getAutoRouter().recordOutcome('chat', message, observation.outcome, this.configManager, observation.outcomeData);
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
// Learning is best-effort — never break a turn on a bandit error.
|
|
872
|
+
}
|
|
873
|
+
// Bundle 3b: the SAME derived verdict also feeds the capability
|
|
874
|
+
// scorecard's accuracy parameter for the pair that actually served the
|
|
875
|
+
// turn. One translation of "did the work verify", two consumers — rather
|
|
876
|
+
// than a second, parallel notion of success that could drift from it.
|
|
877
|
+
// `blocked`/`not-applicable` write nothing (see the registry method).
|
|
878
|
+
try {
|
|
879
|
+
if (type && model) {
|
|
880
|
+
getModelRegistry().recordCapabilityEvidence(type, model, turnReport?.verification ?? 'not-applicable');
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
// Learning is best-effort — never break a turn on a scorecard write.
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
688
888
|
// E3 — surface a non-trivial report on the console. A plain answer (no
|
|
689
889
|
// plan, nothing changed) produces no summary and stays silent.
|
|
690
890
|
if (turnReport?.summary) {
|
|
@@ -704,6 +904,14 @@ export class ChatCommand extends BaseCommand {
|
|
|
704
904
|
unfulfilledPromise: answer.unfulfilledPromise,
|
|
705
905
|
undeliveredArtifact: answer.undeliveredArtifact,
|
|
706
906
|
unverifiedBuildClaim: answer.unverifiedBuildClaim,
|
|
907
|
+
// Bundle 19/20 — the reliability flags must not be dropped at this
|
|
908
|
+
// boundary, or a caller re-deriving the verdict would see a clean turn.
|
|
909
|
+
artifactIncomplete: answer.artifactIncomplete,
|
|
910
|
+
unverifiedFileClaim: answer.unverifiedFileClaim,
|
|
911
|
+
artifactShortfall: answer.artifactShortfall,
|
|
912
|
+
// Item 13 (round 3) — the collectable "fluent, on-length" delivery travels to
|
|
913
|
+
// the caller, which records it. Facts only; no verdict is attached here.
|
|
914
|
+
authoredDeliverable: answer.authoredDeliverable,
|
|
707
915
|
// WS1 — the findings this turn recorded (empty when it recorded none).
|
|
708
916
|
findings: answer.findings ?? [],
|
|
709
917
|
provider: type,
|
|
@@ -791,6 +999,40 @@ export class ChatCommand extends BaseCommand {
|
|
|
791
999
|
// Apply the active model state from `nuvira model switch` as defaults
|
|
792
1000
|
const activeOpts = applyActiveModel({ provider: options?.provider, model: options?.model });
|
|
793
1001
|
const mergedOpts = { ...options, provider: activeOpts.provider, model: activeOpts.model };
|
|
1002
|
+
// ── The CONFIG's defaultProvider is a routing directive too ──────────────
|
|
1003
|
+
// When the operator supplies neither a provider nor a model — no `-p`, no
|
|
1004
|
+
// `-m`, no `nuvira model switch` state — the config decides, and the shipped
|
|
1005
|
+
// default is `defaultProvider: "auto"`. This is the CLI's counterpart to the
|
|
1006
|
+
// block in `answerOnce` (see `chat-answer-once-auto-parity.test.ts`, which
|
|
1007
|
+
// pinned the dashboard/gateway side of it in 2026-09-20).
|
|
1008
|
+
//
|
|
1009
|
+
// WHY IT IS LOAD-BEARING, measured 2026-10-06: `execute` — the whole CLI
|
|
1010
|
+
// entry (`-t/--task`, `chat "<task>"`, the REPL) — did NOT honour it. With
|
|
1011
|
+
// neither flag nor active-model state, `autoMode` was `false`, so the turn
|
|
1012
|
+
// took the NON-auto path and `resolveProvider(config, undefined)` resolved
|
|
1013
|
+
// `defaultProvider: "auto"` through `rankAvailableProviders()` to ONE
|
|
1014
|
+
// concrete provider. Everything downstream of the auto router was therefore
|
|
1015
|
+
// skipped on the product's most common entry point: no `routeMessageAuto`
|
|
1016
|
+
// (so no routing-history row to audit which model served the turn, no
|
|
1017
|
+
// routing cache, and no capability gate), no pin pre-flight, and
|
|
1018
|
+
// `model explain` — which DOES use the auto router — predicting a model the
|
|
1019
|
+
// runtime never used. Measured with `NUVIRA_TRACE_ROUTE=1`: a `-t` turn
|
|
1020
|
+
// logged `[TRACE] execute {"autoMode":false,"type":"groq"}` and appended
|
|
1021
|
+
// ZERO rows to routing-history.
|
|
1022
|
+
//
|
|
1023
|
+
// Only the AUTO default changes behaviour: an explicit `-p`/`-m` or a
|
|
1024
|
+
// `model switch` pin is a deliberate choice and keeps the non-auto path
|
|
1025
|
+
// exactly as it is (and is checked FIRST, so a pin can never be re-routed).
|
|
1026
|
+
if (!mergedOpts.provider && !mergedOpts.model) {
|
|
1027
|
+
try {
|
|
1028
|
+
const cfg = this.configManager.getAll();
|
|
1029
|
+
if (isAutoProvider(cfg.defaultProvider))
|
|
1030
|
+
mergedOpts.provider = cfg.defaultProvider;
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// Best-effort — an unreadable config leaves the previous behavior.
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
794
1036
|
// ── Auto routing mode: agent decides the best provider/model per message ──
|
|
795
1037
|
// A8 — a pinned concrete provider with `-m auto` is the provider's own auto,
|
|
796
1038
|
// not ours. See isAgentAutoRoute.
|
|
@@ -890,6 +1132,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
890
1132
|
// WS5 — never on a REFUSED turn: the pipeline would run it in the real tree
|
|
891
1133
|
// (see the guard in `answerOnce`).
|
|
892
1134
|
if (answer.generationFailed && !answer.refused && dispatchDecision.dispatch && !dispatchDecision.needConfirm) {
|
|
1135
|
+
// E2 — same rule as `answerOnce`: the engine changes here, so say so.
|
|
1136
|
+
logger.info(` ⤵️ the chat turn produced no answer on ${provider.name}${model ? ` / ${model}` : ''} — ` +
|
|
1137
|
+
`running this ask in the multi-agent pipeline instead (a different execution model` +
|
|
1138
|
+
`${autoMode ? '' : ', on the same pinned pair'})`);
|
|
893
1139
|
await runDeveloperMode(prompt, this.configManager, { provider: type, model });
|
|
894
1140
|
// After pipeline execution, show followups and continue conversation
|
|
895
1141
|
// (don't just return — keep user engaged with next steps)
|
|
@@ -1139,7 +1385,20 @@ export class ChatCommand extends BaseCommand {
|
|
|
1139
1385
|
...(ctxOverrides?.debugSession ? { session: ctxOverrides.debugSession } : {}),
|
|
1140
1386
|
backend: { engine: 'loop', provider: session.type, ...(session.model ? { model: session.model } : {}) },
|
|
1141
1387
|
});
|
|
1142
|
-
|
|
1388
|
+
// ── The pair this surface RESOLVED is not the pair that served ───────────
|
|
1389
|
+
// MEASURED (2026-10-07): one log read `# backend.provider: local` /
|
|
1390
|
+
// `# backend.model: qwen2.5:0.5b` while its own first event said
|
|
1391
|
+
// `turn.start {"provider":"gemini"}`. Neither field was computed wrongly —
|
|
1392
|
+
// the header names the pair that ACTUALLY served (via `lastAttempt`, updated
|
|
1393
|
+
// by the provider walk and written at close) and this event named the pair
|
|
1394
|
+
// the turn was CONFIGURED with, before any walk. But a lone `provider` field
|
|
1395
|
+
// in an event called `turn.start` reads as "the turn started on gemini", so
|
|
1396
|
+
// the honest fix is to say which question each field answers: this one is the
|
|
1397
|
+
// REQUESTED route, and `turn.end` now carries the served one from the same
|
|
1398
|
+
// expression as the header, so the two can never disagree.
|
|
1399
|
+
debugLog?.event('turn.start', {
|
|
1400
|
+
requested: session.model ? `${session.type}/${session.model}` : session.type,
|
|
1401
|
+
});
|
|
1143
1402
|
// WS3 (#25) — the turn's span root, when span export is on (else null). Same
|
|
1144
1403
|
// identity as the log's: one surface, one conversation, one turn.
|
|
1145
1404
|
const otelSpan = await startTurnSpan({
|
|
@@ -1314,26 +1573,36 @@ export class ChatCommand extends BaseCommand {
|
|
|
1314
1573
|
// prompt, so on this surface the MODEL decides and the rules are invisible.
|
|
1315
1574
|
const systemText = buildToolSystemPrompt(parsed);
|
|
1316
1575
|
// Skill hint — MODE-DEPENDENT (see resolveSkillHintMode):
|
|
1317
|
-
// - `
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1576
|
+
// - `pointer` (default) — a bounded discovery pointer: skills exist, here
|
|
1577
|
+
// are the two ways to find one (the `skill` tool and the capability
|
|
1578
|
+
// search). The MODEL picks; the harness no longer keyword-matches. This
|
|
1579
|
+
// keeps the prompt small and has no false positives to maintain.
|
|
1320
1580
|
// - `catalog` (opt-in) — the full name+description catalog, which lets the
|
|
1321
|
-
//
|
|
1322
|
-
// chosen (`NUVIRA_SKILL_CATALOG=catalog` or
|
|
1581
|
+
// model read every skill in-context but costs ~24K chars on every turn,
|
|
1582
|
+
// so it must be chosen (`NUVIRA_SKILL_CATALOG=catalog` or
|
|
1583
|
+
// `skills.catalogHint`).
|
|
1323
1584
|
// - `off` — never inject one.
|
|
1324
1585
|
// Best-effort: any failure returns '' and the turn proceeds byte-identically.
|
|
1325
1586
|
let skillHint = '';
|
|
1326
1587
|
try {
|
|
1327
|
-
const { buildConfiguredSkillHint
|
|
1328
|
-
|
|
1329
|
-
value: null,
|
|
1330
|
-
};
|
|
1331
|
-
skillHint = await buildConfiguredSkillHint(message, this.configManager, injected);
|
|
1332
|
-
await markLoopSkillUsed(injected.value);
|
|
1588
|
+
const { buildConfiguredSkillHint } = await import('../tools/loop-skill-hint.js');
|
|
1589
|
+
skillHint = await buildConfiguredSkillHint(this.configManager);
|
|
1333
1590
|
}
|
|
1334
1591
|
catch {
|
|
1335
1592
|
skillHint = ''; // best-effort — a hint failure never breaks the turn
|
|
1336
1593
|
}
|
|
1594
|
+
// Machine facts — the host stated ONCE (OS / arch / shell / installed package
|
|
1595
|
+
// managers), so the model chooses a command for THIS machine instead of
|
|
1596
|
+
// assuming one. Detected once per process; best-effort (a detection failure
|
|
1597
|
+
// injects nothing and the turn proceeds byte-identically).
|
|
1598
|
+
let machineBlock = '';
|
|
1599
|
+
try {
|
|
1600
|
+
const { buildMachineFactsBlock } = await import('../learning/machine-facts.js');
|
|
1601
|
+
machineBlock = buildMachineFactsBlock();
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
machineBlock = '';
|
|
1605
|
+
}
|
|
1337
1606
|
// `-f/--file` file-context parity: the legacy generateWithContext loaded
|
|
1338
1607
|
// + retrieval-reduced file context. Inject it as a context message before
|
|
1339
1608
|
// the user's message so the tool-loop path keeps the flag working.
|
|
@@ -1369,6 +1638,25 @@ export class ChatCommand extends BaseCommand {
|
|
|
1369
1638
|
ambientProjectContext = undefined;
|
|
1370
1639
|
}
|
|
1371
1640
|
}
|
|
1641
|
+
// Phase 1 — deterministic, tag-scoped knowledge. A message that OPENS with
|
|
1642
|
+
// `#tag` retrieves that tag's relevant passages and puts them in front of
|
|
1643
|
+
// the model before the question. It is INERT otherwise: with no marker (or
|
|
1644
|
+
// with no tags defined at all) the builder returns an empty block WITHOUT
|
|
1645
|
+
// touching the embedder, so a turn that did not ask for a document is
|
|
1646
|
+
// byte-identical to one from a build without this feature — no extra cost,
|
|
1647
|
+
// no extra noise, and never a wrong corpus. Dynamically imported so the
|
|
1648
|
+
// retrieval modules are not loaded on a turn that will not use them, the
|
|
1649
|
+
// same best-effort contract as the ambient project context above. See
|
|
1650
|
+
// src/learning/knowledge-turn.ts for what the guarantee rests on.
|
|
1651
|
+
let knowledgeBlock = '';
|
|
1652
|
+
try {
|
|
1653
|
+
const { buildKnowledgeTurnContext } = await import('../learning/knowledge-turn.js');
|
|
1654
|
+
const knowledge = await buildKnowledgeTurnContext(message);
|
|
1655
|
+
knowledgeBlock = knowledge.block;
|
|
1656
|
+
}
|
|
1657
|
+
catch {
|
|
1658
|
+
knowledgeBlock = ''; // best-effort — a retrieval failure never breaks the turn
|
|
1659
|
+
}
|
|
1372
1660
|
// G4 — carry THIS project's working state (files changed, verification debt,
|
|
1373
1661
|
// user-reported regressions) into the turn, so the model does not re-derive
|
|
1374
1662
|
// what previous turns already established. This is the fix for the
|
|
@@ -1390,7 +1678,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
1390
1678
|
// turn, where it used to occupy ~80% of the ask on WhatsApp turns.
|
|
1391
1679
|
const systemPolicyBlock = ctxOverrides?.systemPolicy ? `\n\n${ctxOverrides.systemPolicy}` : '';
|
|
1392
1680
|
const thread = [
|
|
1393
|
-
{ role: 'system', content: systemText + systemPolicyBlock + skillHint },
|
|
1681
|
+
{ role: 'system', content: systemText + systemPolicyBlock + machineBlock + skillHint },
|
|
1394
1682
|
// P3 — the attached project's bounded snapshot (path + file tree +
|
|
1395
1683
|
// symbol map) rides in before the conversation, exactly like --file
|
|
1396
1684
|
// context: the model knows what it is looking at without being told.
|
|
@@ -1401,11 +1689,22 @@ export class ChatCommand extends BaseCommand {
|
|
|
1401
1689
|
: ambientProjectContext
|
|
1402
1690
|
? [{ role: 'user', content: `[Project context]\n${ambientProjectContext}` }]
|
|
1403
1691
|
: []),
|
|
1692
|
+
// Phase 1 — the tagged knowledge this ask selected, when it selected one.
|
|
1693
|
+
// It sits with the other CONTEXT blocks (before the history) so the
|
|
1694
|
+
// conversation stays contiguous, and it is a USER-TURN message rather than
|
|
1695
|
+
// system text, so the system prompt stays byte-stable and cacheable.
|
|
1696
|
+
...(knowledgeBlock ? [{ role: 'user', content: knowledgeBlock }] : []),
|
|
1404
1697
|
// P4 — the recalled project context (prior sessions + facts) rides in
|
|
1405
1698
|
// next, so the model starts from what this project was last doing.
|
|
1406
1699
|
...(ctxOverrides?.recallContext
|
|
1407
1700
|
? [{ role: 'user', content: ctxOverrides.recallContext }]
|
|
1408
1701
|
: []),
|
|
1702
|
+
// Decision recall — the ADVISORY standing answers this project recorded
|
|
1703
|
+
// for related asks. Sits with the other context blocks, and is deliberately
|
|
1704
|
+
// never used to suppress an `ask_user`.
|
|
1705
|
+
...(ctxOverrides?.decisionContext
|
|
1706
|
+
? [{ role: 'user', content: ctxOverrides.decisionContext }]
|
|
1707
|
+
: []),
|
|
1409
1708
|
// G4 — the deterministic working-state ledger (never a summary).
|
|
1410
1709
|
...(workingStateBlock ? [{ role: 'user', content: workingStateBlock }] : []),
|
|
1411
1710
|
...(fileContext
|
|
@@ -1457,6 +1756,18 @@ export class ChatCommand extends BaseCommand {
|
|
|
1457
1756
|
// P4 — when a project is attached, scope tools to its root so the
|
|
1458
1757
|
// agent operates inside the project (not the dashboard server's cwd).
|
|
1459
1758
|
cwd: turnCwd,
|
|
1759
|
+
// Cluster G — the surface's own verdict on whether `turnCwd` is the
|
|
1760
|
+
// user's workspace. `false` means this is an unscoped turn (no attachment,
|
|
1761
|
+
// no folder named in the message, no configured default), so `turnCwd` is
|
|
1762
|
+
// the dashboard process's own directory — a place that belongs to nobody.
|
|
1763
|
+
// A write there must ask; see `unscopedWriteRefusal` in coding-tools.ts.
|
|
1764
|
+
...(ctxOverrides?.unscopedWorkspace ? { workspaceUnscoped: true } : {}),
|
|
1765
|
+
// P7 — is a PERSON watching this turn? It decides `run_terminal`'s DEFAULT
|
|
1766
|
+
// command timeout (60s here vs 120s unattended): two minutes of silence in
|
|
1767
|
+
// front of someone is not patience, it is a bug — the live run spent four
|
|
1768
|
+
// minutes on back-to-back 120-second timeouts while the console showed
|
|
1769
|
+
// nothing, then gave up.
|
|
1770
|
+
interactive: Boolean(ctxOverrides?.askUser) || Boolean(process.stdin.isTTY),
|
|
1460
1771
|
emit: (event, data, source) => {
|
|
1461
1772
|
// G3 — collect mutated file paths from `tool:started` (which carries
|
|
1462
1773
|
// the arguments) so the working-state ledger knows what changed.
|
|
@@ -1624,7 +1935,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
1624
1935
|
{ name: 'system:identity+tool-contract', chars: systemText.length },
|
|
1625
1936
|
{ name: 'system:channel-policy', chars: systemPolicyBlock.length },
|
|
1626
1937
|
{ name: 'skill-hint', chars: skillHint.length, dropPriority: 10 },
|
|
1938
|
+
// Machine facts are FACTS about the host, not a recommendation, so they
|
|
1939
|
+
// drop LAST among the optional blocks (higher priority = kept longer).
|
|
1940
|
+
{ name: 'machine-facts', chars: machineBlock.length, dropPriority: 15 },
|
|
1627
1941
|
{ name: 'working-state', chars: workingStateBlock.length, dropPriority: 25 },
|
|
1942
|
+
{ name: 'decisions', chars: ctxOverrides?.decisionContext?.length ?? 0, dropPriority: 28 },
|
|
1628
1943
|
{ name: 'recall', chars: ctxOverrides?.recallContext?.length ?? 0, dropPriority: 30 },
|
|
1629
1944
|
{
|
|
1630
1945
|
name: 'project-context',
|
|
@@ -1634,10 +1949,17 @@ export class ChatCommand extends BaseCommand {
|
|
|
1634
1949
|
{ name: 'file-context', chars: fileContext?.length ?? 0, dropPriority: 45 },
|
|
1635
1950
|
{ name: 'history+ask', chars: historyChars },
|
|
1636
1951
|
], { budget: contextBudget });
|
|
1637
|
-
// Apply the
|
|
1638
|
-
//
|
|
1639
|
-
|
|
1640
|
-
|
|
1952
|
+
// Apply the ladder steps: drop the optional system blocks the budget names,
|
|
1953
|
+
// rebuilding the system message from the survivors.
|
|
1954
|
+
const trimmed = new Set(report.trims);
|
|
1955
|
+
if ((trimmed.has('skill-hint') || trimmed.has('machine-facts')) && (skillHint || machineBlock)) {
|
|
1956
|
+
thread[0] = {
|
|
1957
|
+
role: 'system',
|
|
1958
|
+
content: systemText +
|
|
1959
|
+
systemPolicyBlock +
|
|
1960
|
+
(trimmed.has('machine-facts') ? '' : machineBlock) +
|
|
1961
|
+
(trimmed.has('skill-hint') ? '' : skillHint),
|
|
1962
|
+
};
|
|
1641
1963
|
}
|
|
1642
1964
|
if (report.level !== 'ok') {
|
|
1643
1965
|
recordTraceEvent(chatTraceId, {
|
|
@@ -1654,6 +1976,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
1654
1976
|
// The instant this turn's model walk began. A failed generation records the
|
|
1655
1977
|
// WALK (below) relative to this mark, so the trace can say what was tried.
|
|
1656
1978
|
const modelWalkMark = Date.now();
|
|
1979
|
+
// P4 — a fresh attempt log for this turn (the report is about what THIS turn
|
|
1980
|
+
// tried, never a stale list from an earlier one).
|
|
1981
|
+
this.turnAttempts = [];
|
|
1657
1982
|
const seenStepDigests = new Set();
|
|
1658
1983
|
const digestPrompt = (p) => {
|
|
1659
1984
|
try {
|
|
@@ -1717,6 +2042,146 @@ export class ChatCommand extends BaseCommand {
|
|
|
1717
2042
|
throw err;
|
|
1718
2043
|
}
|
|
1719
2044
|
};
|
|
2045
|
+
/**
|
|
2046
|
+
* P2 (fix_model_routing) — MID-TURN MODEL HANDOFF.
|
|
2047
|
+
*
|
|
2048
|
+
* The tool loop calls this when the model serving the turn resolved with
|
|
2049
|
+
* NOTHING usable (no answer text, no tool call) and has already had its one
|
|
2050
|
+
* retry. Until now that case re-asked the SAME model: a live dashboard run
|
|
2051
|
+
* took five consecutive empty responses from `local/gpt-oss:120b-cloud` and
|
|
2052
|
+
* ended as `bounded` with zero tool calls, while a healthy Gemini model sat
|
|
2053
|
+
* configured and unused — the user had to say "retry". The loop owns the
|
|
2054
|
+
* retry policy; this owns the POOL.
|
|
2055
|
+
*
|
|
2056
|
+
* Order of business, and why:
|
|
2057
|
+
* 1. Exclude the pair that returned nothing — MODEL-scoped, never the
|
|
2058
|
+
* provider. Free tiers meter per model, so the provider's next-best
|
|
2059
|
+
* model is usually perfectly healthy, and `pushCandidate` in the deep
|
|
2060
|
+
* pool already honours this model-scoped exclusion set.
|
|
2061
|
+
* 2. Auto turns re-resolve through the same deep pool the first route used,
|
|
2062
|
+
* first WITHOUT excluding the provider (its sibling models are the
|
|
2063
|
+
* cheapest, lowest-latency handoff) and then WITH it, so a provider
|
|
2064
|
+
* whose only models are dead is genuinely left behind.
|
|
2065
|
+
* 3. A pinned (non-auto) turn walks the configured fallback chain, and
|
|
2066
|
+
* honours `strictModelMode()` by refusing to substitute at all — the
|
|
2067
|
+
* same rule the thrown-error failover already follows.
|
|
2068
|
+
*
|
|
2069
|
+
* Resolving `false` is honest: it means no other candidate exists, and the
|
|
2070
|
+
* loop then falls back to its own bounded escalation instead of spinning.
|
|
2071
|
+
*/
|
|
2072
|
+
const requestModelSwitch = async () => {
|
|
2073
|
+
const failedModel = session.model;
|
|
2074
|
+
// P4 — this pair is one of the models the turn actually tried, and it
|
|
2075
|
+
// failed by answering with nothing. Recorded here because the report has
|
|
2076
|
+
// to name it, and because "the provider returned an empty response" is a
|
|
2077
|
+
// cause the user should see instead of a generic apology.
|
|
2078
|
+
this.recordTurnAttempt({
|
|
2079
|
+
provider: session.type,
|
|
2080
|
+
model: failedModel ?? 'default',
|
|
2081
|
+
kind: 'empty-response',
|
|
2082
|
+
reason: describeFailureKind('empty-response'),
|
|
2083
|
+
});
|
|
2084
|
+
// ── RC2: the failure must be LEARNED, not just survived ───────────────
|
|
2085
|
+
// The session exclusion alone only lasts this turn: the model stayed
|
|
2086
|
+
// `verified` with `errorRate` 0 across sessions, so the very next chat
|
|
2087
|
+
// could route to it again. `recordAutoProviderFailure` runs the FULL
|
|
2088
|
+
// shared composition for the pair — session exclusion (model-scoped),
|
|
2089
|
+
// registry write-through (which parks it for EMPTY_RESPONSE_PARK_MS), the
|
|
2090
|
+
// quota-timeline event, and the shared breaker (deliberately skipped for
|
|
2091
|
+
// an empty response: the breaker is provider-scoped and the provider was
|
|
2092
|
+
// fine). One `empty-response` failure nobody wrote down was the whole
|
|
2093
|
+
// reason a dead model kept its "verified" badge.
|
|
2094
|
+
try {
|
|
2095
|
+
this.recordAutoProviderFailure(session.type, new UnusableModelResponseError('empty', 'the provider returned nothing usable for this step'), failedModel);
|
|
2096
|
+
}
|
|
2097
|
+
catch {
|
|
2098
|
+
// Best-effort — bookkeeping must never break the turn.
|
|
2099
|
+
}
|
|
2100
|
+
// P6 — and the bandit hears about it too: a step-level empty is NEGATIVE
|
|
2101
|
+
// evidence for this model in the arm the router samples (see
|
|
2102
|
+
// `RouterBandit.penalizeModel`), so the score that flipped a working run to
|
|
2103
|
+
// a broken model can actually decay.
|
|
2104
|
+
if (failedModel && failedModel !== 'default') {
|
|
2105
|
+
try {
|
|
2106
|
+
getRouterBandit().penalizeModel(failedModel, this.lastRouteSnapshot?.complexity ?? undefined, this.lastTaskSignature?.split('|')[0]);
|
|
2107
|
+
}
|
|
2108
|
+
catch {
|
|
2109
|
+
// Best-effort — learning must never break the turn.
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/** Install a candidate and report whether it is genuinely a NEW pair. */
|
|
2113
|
+
const install = (next) => {
|
|
2114
|
+
const current = `${session.type}|${session.model ?? 'default'}`;
|
|
2115
|
+
const pair = `${next.type}|${next.model ?? 'default'}`;
|
|
2116
|
+
if (pair === current)
|
|
2117
|
+
return false;
|
|
2118
|
+
session.type = next.type;
|
|
2119
|
+
session.provider = next.provider;
|
|
2120
|
+
if (next.model && next.model !== 'default')
|
|
2121
|
+
session.model = next.model;
|
|
2122
|
+
this.lastAttempt = { provider: next.type, model: next.model };
|
|
2123
|
+
logger.warn(` 🔀 ${current} returned nothing usable — handing the turn to ${pair}`);
|
|
2124
|
+
return true;
|
|
2125
|
+
};
|
|
2126
|
+
if (mode.auto) {
|
|
2127
|
+
// First keep the provider (its sibling models are the cheapest handoff),
|
|
2128
|
+
// then drop the provider entirely so a fully-dead provider is left behind.
|
|
2129
|
+
// The same intent hint the initial route and the failover walk use: a
|
|
2130
|
+
// bare continuation ("resume") must re-route on the ask it continues,
|
|
2131
|
+
// not on the signal-free word, or the handoff lands on a trivial model.
|
|
2132
|
+
const switchRoutingText = continuationSoftwareText(message, history) ?? undefined;
|
|
2133
|
+
for (const excludeProviders of [[], [session.type]]) {
|
|
2134
|
+
try {
|
|
2135
|
+
const next = await this.routeMessageAuto(message, excludeProviders, {
|
|
2136
|
+
...(switchRoutingText ? { routingText: switchRoutingText } : {}),
|
|
2137
|
+
fallbackFrom: session.type,
|
|
2138
|
+
// A handoff must NEVER reuse a decision computed before the failure
|
|
2139
|
+
// it is reacting to. The 30-second routing cache is keyed on inputs
|
|
2140
|
+
// that do not include the pair we just excluded, so a cached
|
|
2141
|
+
// decision could hand back the SAME model — making the handoff a
|
|
2142
|
+
// no-op and ending a recoverable turn. Observed shape: two routing
|
|
2143
|
+
// decisions 17 seconds apart carrying an identical score, one of
|
|
2144
|
+
// them a failover the cache had never re-evaluated.
|
|
2145
|
+
noCache: true,
|
|
2146
|
+
});
|
|
2147
|
+
if (install(next))
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
catch {
|
|
2151
|
+
// Unresolvable through this shape — try the next one.
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
return false;
|
|
2155
|
+
}
|
|
2156
|
+
// A pinned turn: never substitute a model the user explicitly forbade.
|
|
2157
|
+
if (strictModelMode())
|
|
2158
|
+
return false;
|
|
2159
|
+
try {
|
|
2160
|
+
const chain = getProviderFallback(this.configManager, this.configManager.getAll().fallback)
|
|
2161
|
+
.getFallbackChain(session.type);
|
|
2162
|
+
for (const fbType of chain) {
|
|
2163
|
+
if (fbType === session.type)
|
|
2164
|
+
continue;
|
|
2165
|
+
if (!hasCredentials(this.configManager, fbType))
|
|
2166
|
+
continue;
|
|
2167
|
+
if (!governanceVerdict(this.configManager, fbType, { taskText: message }).allowed)
|
|
2168
|
+
continue;
|
|
2169
|
+
try {
|
|
2170
|
+
const resolved = resolveProvider(this.configManager, fbType);
|
|
2171
|
+
const model = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
|
|
2172
|
+
if (install({ type: resolved.type, provider: resolved.provider, model }))
|
|
2173
|
+
return true;
|
|
2174
|
+
}
|
|
2175
|
+
catch {
|
|
2176
|
+
// Next fallback candidate.
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
catch {
|
|
2181
|
+
// Fall through to `false` — the loop's bounded path still applies.
|
|
2182
|
+
}
|
|
2183
|
+
return false;
|
|
2184
|
+
};
|
|
1720
2185
|
try {
|
|
1721
2186
|
// R1 — the harness follows the MODEL, not only the config: config says
|
|
1722
2187
|
// what this deployment prefers, the profile decides what this model can
|
|
@@ -1758,6 +2223,13 @@ export class ChatCommand extends BaseCommand {
|
|
|
1758
2223
|
onTraceEvent: (event) => recordTraceEvent(chatTraceId, event),
|
|
1759
2224
|
deps: {
|
|
1760
2225
|
callModel: callModelWithTrace,
|
|
2226
|
+
// P2 — the empty-response handoff (see `requestModelSwitch` above).
|
|
2227
|
+
requestModelSwitch,
|
|
2228
|
+
// C5 — `requestModelSwitch` installs the replacement on `session`, so
|
|
2229
|
+
// re-resolve the thread budget from the session AFTER a handoff: a
|
|
2230
|
+
// handoff to a smaller window is the case that overflows, and the
|
|
2231
|
+
// budget in hand was computed from the model that just failed.
|
|
2232
|
+
threadBudgetAfterSwitch: () => resolveThreadBudgetChars({ provider: session.type, model: session.model }),
|
|
1761
2233
|
executeTool: async (name, args, ctx) => {
|
|
1762
2234
|
const tool = getTool(name);
|
|
1763
2235
|
if (!tool)
|
|
@@ -1778,6 +2250,19 @@ export class ChatCommand extends BaseCommand {
|
|
|
1778
2250
|
},
|
|
1779
2251
|
},
|
|
1780
2252
|
});
|
|
2253
|
+
// P3 — REMEMBER the worker that actually delivered this task, so the next
|
|
2254
|
+
// turn of the SAME task ("resume", "continue") prefers it instead of
|
|
2255
|
+
// re-deciding from a signal-free word. Only a DELIVERED turn is remembered:
|
|
2256
|
+
// a bounded, cancelled or failed one has taught us nothing about which
|
|
2257
|
+
// model can do this job, and remembering it would propagate the failure.
|
|
2258
|
+
if (!result.cancelled &&
|
|
2259
|
+
!result.generationFailed &&
|
|
2260
|
+
!result.bounded &&
|
|
2261
|
+
!result.termination?.includes('no-capable') &&
|
|
2262
|
+
result.content.trim() &&
|
|
2263
|
+
session.model) {
|
|
2264
|
+
this.taskContinuity.remember(this.lastTaskSignature ?? taskSignature(undefined, undefined), session.type, session.model);
|
|
2265
|
+
}
|
|
1781
2266
|
}
|
|
1782
2267
|
catch (err) {
|
|
1783
2268
|
// The tool loop does not throw on its own; this catches the errors that
|
|
@@ -1834,6 +2319,71 @@ export class ChatCommand extends BaseCommand {
|
|
|
1834
2319
|
generationFailed: true,
|
|
1835
2320
|
};
|
|
1836
2321
|
}
|
|
2322
|
+
// ── P4 — THE HONEST EXHAUSTION REPORT ────────────────────────────────
|
|
2323
|
+
// A turn that ends without an answer used to deliver a bare line ("the
|
|
2324
|
+
// language model was unavailable") or, worse, a QUESTION that made the user
|
|
2325
|
+
// the retry button. Instead, the user gets the measured facts: which models
|
|
2326
|
+
// were tried and why each did not answer, which targets are unavailable and
|
|
2327
|
+
// the WALL-CLOCK time each frees up, whether this was a routing gap or a real
|
|
2328
|
+
// shortage, and the levers only they can pull — with no request to retry.
|
|
2329
|
+
//
|
|
2330
|
+
// Gated on an actual ATTEMPT (or the loop's evidenced `no-capable-candidate`):
|
|
2331
|
+
// a governance refusal, an admin-budget stop or a bad pin fails with a
|
|
2332
|
+
// healthy pool and a message that is already the right answer, and dressing
|
|
2333
|
+
// those up as exhaustion would be a new lie in place of the old one.
|
|
2334
|
+
if (!result.cancelled &&
|
|
2335
|
+
(result.generationFailed || result.termination === 'no-capable-candidate')) {
|
|
2336
|
+
try {
|
|
2337
|
+
const breadth = modelBreadthReport(modelWalkMark, this.configManager);
|
|
2338
|
+
const seen = new Set(this.turnAttempts.map((a) => `${a.provider}|${a.model}|${a.kind}`));
|
|
2339
|
+
const attempts = [...this.turnAttempts];
|
|
2340
|
+
for (const a of breadth.tried) {
|
|
2341
|
+
const key = `${a.provider}|${a.model}|${a.kind}`;
|
|
2342
|
+
if (seen.has(key))
|
|
2343
|
+
continue;
|
|
2344
|
+
seen.add(key);
|
|
2345
|
+
attempts.push({
|
|
2346
|
+
provider: a.provider,
|
|
2347
|
+
model: a.model,
|
|
2348
|
+
kind: a.kind,
|
|
2349
|
+
reason: a.reason || describeFailureKind(a.kind),
|
|
2350
|
+
...(a.skipped ? { skipped: true } : {}),
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
if (attempts.length > 0) {
|
|
2354
|
+
const exclusions = breadth.parked
|
|
2355
|
+
.filter((r) => r.active)
|
|
2356
|
+
.map((r) => ({
|
|
2357
|
+
provider: r.provider,
|
|
2358
|
+
...(r.model ? { model: r.model } : {}),
|
|
2359
|
+
kind: r.kind,
|
|
2360
|
+
...(r.expiresAt ? { expiresAt: r.expiresAt } : {}),
|
|
2361
|
+
...(r.source ? { source: r.source } : {}),
|
|
2362
|
+
}));
|
|
2363
|
+
const reportText = renderExhaustionReport({
|
|
2364
|
+
attempts,
|
|
2365
|
+
exclusions,
|
|
2366
|
+
...(typeof breadth.poolSize === 'number' ? { poolSize: breadth.poolSize } : {}),
|
|
2367
|
+
...(typeof breadth.poolProviders === 'number' ? { poolProviders: breadth.poolProviders } : {}),
|
|
2368
|
+
task: message,
|
|
2369
|
+
// The underlying line is kept as the REASON, so a specific,
|
|
2370
|
+
// actionable cause is never buried by the report.
|
|
2371
|
+
...(result.generationFailed && result.content.trim() ? { cause: result.content } : {}),
|
|
2372
|
+
});
|
|
2373
|
+
if (reportText) {
|
|
2374
|
+
result.content = reportText;
|
|
2375
|
+
recordTraceEvent(chatTraceId, {
|
|
2376
|
+
kind: 'failover',
|
|
2377
|
+
summary: `exhaustion report delivered — ${attempts.filter((a) => !a.skipped).length} model(s) tried, ` +
|
|
2378
|
+
`${exclusions.length} unavailable, eligible pool ${breadth.poolSize ?? '?'}`,
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
catch {
|
|
2384
|
+
// The report is a courtesy — the turn's own failure line still stands.
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
1837
2387
|
// Record WHAT HAPPENED, not just "the model answered": a hallucinated
|
|
1838
2388
|
// "I have sent it" (no tool ran) must be visible as an unverified claim in
|
|
1839
2389
|
// the Trace tab instead of looking like a real delivery.
|
|
@@ -1850,6 +2400,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
1850
2400
|
unverifiedEditClaim: result.unverifiedEditClaim,
|
|
1851
2401
|
undeliveredArtifact: result.undeliveredArtifact,
|
|
1852
2402
|
unverifiedBuildClaim: result.unverifiedBuildClaim,
|
|
2403
|
+
// Bundle 19/20 — the artifact's own omission, and a file claim no write
|
|
2404
|
+
// backs, both make the turn `incomplete` on the trace (not `answered`). incompleteArtifactClaim: Boolean(result.artifactIncomplete),
|
|
2405
|
+
unverifiedFileClaim: result.unverifiedFileClaim,
|
|
2406
|
+
artifactShortfall: Boolean(result.artifactShortfall),
|
|
1853
2407
|
});
|
|
1854
2408
|
// A cancelled / failed / incomplete turn is NOT a success. `!generationFailed`
|
|
1855
2409
|
// used to let a cancelled run record `success: true` while its outcome said
|
|
@@ -1913,6 +2467,31 @@ export class ChatCommand extends BaseCommand {
|
|
|
1913
2467
|
if (result.unverifiedBuildClaim) {
|
|
1914
2468
|
logger.warn(' ⚠️ A build command FAILED this turn and no later build succeeded, but the reply reports success — treat the build as UNVERIFIED.');
|
|
1915
2469
|
}
|
|
2470
|
+
// Bundle 19 — the ARTIFACT-honesty warning. The file this turn wrote says its
|
|
2471
|
+
// own content was omitted, so the prose above describes a document that is not
|
|
2472
|
+
// on disk. The artifact's OWN WORDS are quoted, so the reader sees the evidence
|
|
2473
|
+
// (a specific line the file contains) rather than a verdict they must trust.
|
|
2474
|
+
// Without this the flag only changed the internal verdict; the user still saw
|
|
2475
|
+
// a confident "complete" reply with no sign of the admission inside it.
|
|
2476
|
+
if (result.artifactIncomplete) {
|
|
2477
|
+
logger.warn(` ⚠️ The file ${result.artifactIncomplete.path} says its own content was omitted ("${result.artifactIncomplete.statement}") — the artifact is INCOMPLETE, whatever the reply above claims.`);
|
|
2478
|
+
}
|
|
2479
|
+
// Bundle 20 — the FILE-CLAIM warning. The reply above says a file was
|
|
2480
|
+
// written and the turn's own record says none was. Found live: a turn wrote
|
|
2481
|
+
// nothing and replied "The guide has been written and saved as GUIDE.md …
|
|
2482
|
+
// contains a full ~5,000-word technical guide" — and the user saw no warning,
|
|
2483
|
+
// because the flag that exists for this failure is gated on the ASK and the
|
|
2484
|
+
// ask was not recognised as a deliverable. This one keys on the CLAIM.
|
|
2485
|
+
if (result.unverifiedFileClaim) {
|
|
2486
|
+
logger.warn(' ⚠️ This reply says a file was written, but NO file was written this turn — treat the deliverable as MISSING, not as done.');
|
|
2487
|
+
}
|
|
2488
|
+
// Bundle 23 (item 13) — the ARTIFACT-VS-ASK warning. The ask named a
|
|
2489
|
+
// magnitude and the file this turn wrote is far short of it; the numbers are
|
|
2490
|
+
// quoted so the reader sees the gap rather than a verdict.
|
|
2491
|
+
if (result.artifactShortfall) {
|
|
2492
|
+
const s = result.artifactShortfall;
|
|
2493
|
+
logger.warn(` ⚠️ ${s.path} has ~${s.deliveredWords} words, but the request asked for ~${s.targetWords} (${s.source}) — the deliverable is materially SHORT of what was asked.`);
|
|
2494
|
+
}
|
|
1916
2495
|
}
|
|
1917
2496
|
catch {
|
|
1918
2497
|
// Best-effort.
|
|
@@ -1979,11 +2558,18 @@ export class ChatCommand extends BaseCommand {
|
|
|
1979
2558
|
// affect the answer.
|
|
1980
2559
|
if (debugLog) {
|
|
1981
2560
|
const servedModel = this.lastAttempt?.model ?? session.model;
|
|
2561
|
+
// ONE expression for the served pair, used by BOTH the header and the
|
|
2562
|
+
// `turn.end` event — so "what the header says" and "what the events say"
|
|
2563
|
+
// is a single fact rather than two that can drift (the A5/D3 class of
|
|
2564
|
+
// defect, in its last remaining face).
|
|
2565
|
+
const servedProvider = this.lastAttempt?.provider ?? session.type;
|
|
1982
2566
|
debugLog.backendOf({
|
|
1983
|
-
provider:
|
|
2567
|
+
provider: servedProvider,
|
|
1984
2568
|
...(servedModel ? { model: servedModel } : {}),
|
|
1985
2569
|
transport: result.transport ?? null,
|
|
1986
2570
|
});
|
|
2571
|
+
const requested = session.model ? `${session.type}/${session.model}` : session.type;
|
|
2572
|
+
const served = servedModel ? `${servedProvider}/${servedModel}` : servedProvider;
|
|
1987
2573
|
debugLog.event('turn.end', {
|
|
1988
2574
|
generationFailed: result.generationFailed === true,
|
|
1989
2575
|
cancelled: result.cancelled === true,
|
|
@@ -1991,6 +2577,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
1991
2577
|
contentChars: result.content.length,
|
|
1992
2578
|
toolCalls: result.toolCalls?.length ?? 0,
|
|
1993
2579
|
findings: findings.length,
|
|
2580
|
+
// The pair that answered, and (only when it differs) the pair the turn
|
|
2581
|
+
// asked for — a reader can then tell a straight run from a failover
|
|
2582
|
+
// without cross-referencing the header.
|
|
2583
|
+
served,
|
|
2584
|
+
...(served === requested ? {} : { requested }),
|
|
1994
2585
|
});
|
|
1995
2586
|
const notice = debugLogNotice(ctxOverrides?.debugSurface ?? 'cli-chat', debugLog.write());
|
|
1996
2587
|
if (notice)
|
|
@@ -2025,6 +2616,11 @@ export class ChatCommand extends BaseCommand {
|
|
|
2025
2616
|
unfulfilledPromise: result.unfulfilledPromise,
|
|
2026
2617
|
undeliveredArtifact: result.undeliveredArtifact,
|
|
2027
2618
|
unverifiedBuildClaim: result.unverifiedBuildClaim,
|
|
2619
|
+
// Bundle 20 — an unbacked file claim (or a self-admitted omission) means
|
|
2620
|
+
// the work did not conclude, so the continuation affordances must appear.
|
|
2621
|
+
incompleteArtifactClaim: Boolean(result.artifactIncomplete),
|
|
2622
|
+
unverifiedFileClaim: result.unverifiedFileClaim,
|
|
2623
|
+
artifactShortfall: Boolean(result.artifactShortfall),
|
|
2028
2624
|
});
|
|
2029
2625
|
const chatFollowups = withContinuationFollowups(result.followups, {
|
|
2030
2626
|
unfinished: !traceOutcomeSucceeded(chatOutcomeForFollowups),
|
|
@@ -2041,6 +2637,14 @@ export class ChatCommand extends BaseCommand {
|
|
|
2041
2637
|
unfulfilledPromise: result.unfulfilledPromise,
|
|
2042
2638
|
undeliveredArtifact: result.undeliveredArtifact,
|
|
2043
2639
|
unverifiedBuildClaim: result.unverifiedBuildClaim,
|
|
2640
|
+
// Bundle 19/20 — carried to the caller so the honesty flags survive the
|
|
2641
|
+
// return boundary instead of only living in the turn report.
|
|
2642
|
+
artifactIncomplete: result.artifactIncomplete,
|
|
2643
|
+
unverifiedFileClaim: result.unverifiedFileClaim,
|
|
2644
|
+
artifactShortfall: result.artifactShortfall,
|
|
2645
|
+
// Item 13 (round 3) — the collectable "fluent, on-length" delivery survives
|
|
2646
|
+
// this boundary too, so `answerOnce` can record it as corpus facts.
|
|
2647
|
+
authoredDeliverable: result.authoredDeliverable,
|
|
2044
2648
|
// R2 — the transport this turn travelled on (interactive REPL path).
|
|
2045
2649
|
transport: result.transport,
|
|
2046
2650
|
// WS1 — the findings this turn recorded, with their verdicts.
|
|
@@ -2164,6 +2768,32 @@ export class ChatCommand extends BaseCommand {
|
|
|
2164
2768
|
throw new Error(`Admin budget: ${budget.reason}`);
|
|
2165
2769
|
}
|
|
2166
2770
|
}
|
|
2771
|
+
/**
|
|
2772
|
+
* A2 — REACHABILITY PRE-FLIGHT (pinned path).
|
|
2773
|
+
*
|
|
2774
|
+
* The governance/admin checks above cover POLICY; this covers whether the
|
|
2775
|
+
* pin can work at all. Measured (Run D): `-p openrouter -m
|
|
2776
|
+
* deepseek/deepseek-v4.1-flash` on an unfunded account was sent, and the
|
|
2777
|
+
* operator learned the real cause from a raw 402 body — after a full round
|
|
2778
|
+
* trip, with a live 6-second failure that said nothing about what to do.
|
|
2779
|
+
*
|
|
2780
|
+
* `resolveRoute` refuses that pin, and every one of its callers therefore
|
|
2781
|
+
* does — but this method resolves its own model, so after the first fix the
|
|
2782
|
+
* same run still logged BOTH behaviours: the pipeline refused the pair
|
|
2783
|
+
* before any call ("Refusing to call …") while the chat tool loop sent the
|
|
2784
|
+
* request and got `402 Insufficient credits` back. Same gate, same wording,
|
|
2785
|
+
* same implementation (`strictPinRefusal`) — this is the one seam that was
|
|
2786
|
+
* still missing it.
|
|
2787
|
+
*
|
|
2788
|
+
* Strict mode only, exactly like `resolveRoute`: the refusal sentence says
|
|
2789
|
+
* strict mode forbids substituting, and on a non-strict turn the fallback
|
|
2790
|
+
* walk is the correct repair.
|
|
2791
|
+
*/
|
|
2792
|
+
if (!mode.auto && strictModelMode()) {
|
|
2793
|
+
const refusal = strictPinRefusal(session.type, session.model);
|
|
2794
|
+
if (refusal)
|
|
2795
|
+
throw new Error(refusal);
|
|
2796
|
+
}
|
|
2167
2797
|
const resolveEffectiveModel = (providerType, requested) => {
|
|
2168
2798
|
if (requested && requested !== 'default')
|
|
2169
2799
|
return requested;
|
|
@@ -2306,6 +2936,16 @@ export class ChatCommand extends BaseCommand {
|
|
|
2306
2936
|
return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`));
|
|
2307
2937
|
}
|
|
2308
2938
|
catch (err) {
|
|
2939
|
+
// P4 — the model this turn STARTED on failed. Recorded with a human
|
|
2940
|
+
// phrase (never the raw provider text, which is how a JSON quota dump
|
|
2941
|
+
// once reached a user verbatim) so the exhaustion report can name it.
|
|
2942
|
+
const primaryKind = classifyFallbackError(err);
|
|
2943
|
+
this.recordTurnAttempt({
|
|
2944
|
+
provider: session.type,
|
|
2945
|
+
model: session.model ?? 'default',
|
|
2946
|
+
kind: primaryKind,
|
|
2947
|
+
reason: describeFailureKind(primaryKind),
|
|
2948
|
+
});
|
|
2309
2949
|
// Auto mode: fail over across the ranked candidates (never stuck).
|
|
2310
2950
|
if (mode.auto) {
|
|
2311
2951
|
const firstType = session.type;
|
|
@@ -2380,7 +3020,16 @@ export class ChatCommand extends BaseCommand {
|
|
|
2380
3020
|
logger.success(`✅ Auto failover: answered from ${next.provider.name} (${next.model}) after ${firstType} failed`);
|
|
2381
3021
|
return resp;
|
|
2382
3022
|
}
|
|
2383
|
-
catch {
|
|
3023
|
+
catch (nextErr) {
|
|
3024
|
+
// P4 — record THIS candidate's failure too, so the report lists
|
|
3025
|
+
// every model the turn was actually served by, in order.
|
|
3026
|
+
const nextKind = classifyFallbackError(nextErr);
|
|
3027
|
+
this.recordTurnAttempt({
|
|
3028
|
+
provider: next.type,
|
|
3029
|
+
model: next.model ?? 'default',
|
|
3030
|
+
kind: nextKind,
|
|
3031
|
+
reason: describeFailureKind(nextKind),
|
|
3032
|
+
});
|
|
2384
3033
|
// Next candidate.
|
|
2385
3034
|
}
|
|
2386
3035
|
}
|
|
@@ -2418,6 +3067,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
2418
3067
|
catch {
|
|
2419
3068
|
// Ordering is an optimization only.
|
|
2420
3069
|
}
|
|
3070
|
+
// The pair the turn was PINNED to. Captured before the walk because
|
|
3071
|
+
// the walk now installs what it serves (see below), and the warning
|
|
3072
|
+
// must still name the provider that actually failed.
|
|
3073
|
+
const pinnedType = session.type;
|
|
2421
3074
|
for (const fbType of ordered) {
|
|
2422
3075
|
if (fbType === session.type)
|
|
2423
3076
|
continue;
|
|
@@ -2455,7 +3108,40 @@ export class ChatCommand extends BaseCommand {
|
|
|
2455
3108
|
// the dashboard console and the gateway chat engine - looked
|
|
2456
3109
|
// dead.) Undefined = that provider's configured/adapter
|
|
2457
3110
|
// default, which tryGenerate resolves per attempt.
|
|
2458
|
-
|
|
3111
|
+
const fallbackModel = resolveEffectiveModel(resolved.type, undefined);
|
|
3112
|
+
const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
|
|
3113
|
+
// ── The pair that ANSWERED is the pair the turn ran on ───────
|
|
3114
|
+
// This walk used to serve the fallback and leave `session` still
|
|
3115
|
+
// naming the provider that had just failed. Everything the turn
|
|
3116
|
+
// then reported or learned read the WRONG provider against the
|
|
3117
|
+
// RIGHT model — one interlocking defect with four faces, all
|
|
3118
|
+
// measured:
|
|
3119
|
+
// • the session debug log's `turn.start` named the pinned
|
|
3120
|
+
// provider while its own header named the one that served
|
|
3121
|
+
// (`backend.provider: deepseek` with `turn.start {"provider":
|
|
3122
|
+
// "groq"}` — A5/D3);
|
|
3123
|
+
// • `servedRoute()` told the MODEL it was a different model
|
|
3124
|
+
// than the one answering;
|
|
3125
|
+
// • the response cache stored `provider: "groq"` beside
|
|
3126
|
+
// `model: "deepseek-flash"`;
|
|
3127
|
+
// • and worst, `recordRegistrySuccess(session.type,
|
|
3128
|
+
// session.model)` wrote a PAIR THAT CANNOT EXIST into the
|
|
3129
|
+
// model registry as `verified`. That is the self-poisoning:
|
|
3130
|
+
// `groq|deepseek-flash` and `groq|gemini-3.1-flash-lite` are
|
|
3131
|
+
// both sitting in the live registry as `telemetry`-verified,
|
|
3132
|
+
// and because `telemetry` outranks `spot-check` in
|
|
3133
|
+
// `preferredModelsFor`, the NEXT groq call picked the bogus
|
|
3134
|
+
// id as its adapter default, sent a foreign model name to
|
|
3135
|
+
// groq, and 404'd into the same fallback walk — each run
|
|
3136
|
+
// making the next one worse.
|
|
3137
|
+
// The auto branch above has always installed `next`; the pinned
|
|
3138
|
+
// branch is the one that drifted.
|
|
3139
|
+
session.type = resolved.type;
|
|
3140
|
+
session.provider = resolved.provider;
|
|
3141
|
+
if (fallbackModel)
|
|
3142
|
+
session.model = fallbackModel;
|
|
3143
|
+
logger.warn(` 🔀 ${pinnedType} was unavailable — answered from ${resolved.provider.name} (${fallbackModel ?? 'default'})`);
|
|
3144
|
+
return resp;
|
|
2459
3145
|
}
|
|
2460
3146
|
catch {
|
|
2461
3147
|
// Next fallback candidate.
|
|
@@ -2669,16 +3355,25 @@ export class ChatCommand extends BaseCommand {
|
|
|
2669
3355
|
.sort()
|
|
2670
3356
|
.join(','),
|
|
2671
3357
|
]);
|
|
2672
|
-
const
|
|
3358
|
+
const resolveFresh = () => getAutoRouter().resolve('chat', taskText, {
|
|
2673
3359
|
...buildAutoResolveOptions(this.configManager, {
|
|
2674
3360
|
verbose: envBuff('DEBUG') === 'true',
|
|
2675
3361
|
contextHintTokens: opts?.contextHintTokens,
|
|
2676
3362
|
}),
|
|
2677
3363
|
circuitBreakerStatus,
|
|
2678
3364
|
...(dispatch.taskIntentHint ? { taskIntentHint: dispatch.taskIntentHint } : {}),
|
|
2679
|
-
}, this.configManager)
|
|
3365
|
+
}, this.configManager);
|
|
3366
|
+
const decision = opts?.noCache === true
|
|
3367
|
+
? resolveFresh()
|
|
3368
|
+
: withRoutingCache(cacheSignature, 30_000, resolveFresh);
|
|
2680
3369
|
// G5 — record the routing snapshot now (the winner + why), so the reasoning
|
|
2681
3370
|
// trace for this turn can show the decision instead of a blank column.
|
|
3371
|
+
// P3 — the TASK this turn belongs to, as far as model continuity is
|
|
3372
|
+
// concerned. Kept on the instance because the turn's SUCCESS path (the only
|
|
3373
|
+
// place that knows a model actually DELIVERED) does not hold the routing
|
|
3374
|
+
// decision; intent+complexity is the same key for the original ask and for a
|
|
3375
|
+
// continuation of it, which is exactly the pair that must match.
|
|
3376
|
+
this.lastTaskSignature = taskSignature(dispatch.taskIntentHint ?? decision.taskProfile?.intent, String(decision.complexity));
|
|
2682
3377
|
this.lastRouteSnapshot = {
|
|
2683
3378
|
provider: decision.provider,
|
|
2684
3379
|
model: decision.model,
|
|
@@ -2689,6 +3384,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
2689
3384
|
// and console can show WHY the pair was chosen, not just which pair.
|
|
2690
3385
|
agenticCapable: decision.agenticCapable,
|
|
2691
3386
|
overrideReason: decision.overrideReason,
|
|
3387
|
+
// Bundle 36 — the intent the bandit will bucket this outcome under, so a
|
|
3388
|
+
// later `nuvira rate bad` can correct the EXACT arm (see `verdict-routing.ts`).
|
|
3389
|
+
...(decision.taskProfile?.intent ? { taskIntent: decision.taskProfile.intent } : {}),
|
|
2692
3390
|
};
|
|
2693
3391
|
// Walk the ranked candidates (winner first) and return the first available
|
|
2694
3392
|
// provider — never a provider that lacks a key or endpoint. Providers that
|
|
@@ -2779,9 +3477,15 @@ export class ChatCommand extends BaseCommand {
|
|
|
2779
3477
|
crossPipelineMemory: false,
|
|
2780
3478
|
registryCheck: false,
|
|
2781
3479
|
});
|
|
3480
|
+
// B2-a — each candidate carries the score OF ITS OWN PAIR, when the pool had
|
|
3481
|
+
// a real measurement for it, plus the scale that score is on. It used to
|
|
3482
|
+
// carry nothing at all and every row was recorded with `decision.score`, so
|
|
3483
|
+
// three different pairs shared one provider-level number (measured live:
|
|
3484
|
+
// `0.43836864406779663` on gemini/gemma-4-26b-a4b-it,
|
|
3485
|
+
// openrouter/cohere/command-r7b-12-2024 and deepseek/deepseek-flash).
|
|
2782
3486
|
const chatCandidates = [];
|
|
2783
3487
|
const seenPairs = new Set();
|
|
2784
|
-
const pushCandidate = (prov, mdl) => {
|
|
3488
|
+
const pushCandidate = (prov, mdl, score, scoreBasis) => {
|
|
2785
3489
|
if (!prov || excluded.has(prov) || registryBlocked.has(prov))
|
|
2786
3490
|
return;
|
|
2787
3491
|
const model = mdl && mdl !== 'default' ? mdl : 'default';
|
|
@@ -2799,7 +3503,13 @@ export class ChatCommand extends BaseCommand {
|
|
|
2799
3503
|
// already ranks healthy models first, so the parked pick is only ever a
|
|
2800
3504
|
// last resort that the repair then fixes.
|
|
2801
3505
|
seenPairs.add(key);
|
|
2802
|
-
chatCandidates.push({
|
|
3506
|
+
chatCandidates.push({
|
|
3507
|
+
provider: prov,
|
|
3508
|
+
model,
|
|
3509
|
+
// Only a labelled measurement travels: an ordering placeholder must not
|
|
3510
|
+
// reach the audit trail as if it described this pair.
|
|
3511
|
+
...(scoreBasis ? { score, scoreBasis } : {}),
|
|
3512
|
+
});
|
|
2803
3513
|
};
|
|
2804
3514
|
// The pool itself is the SAME one the orchestrator/tool/sub-agent path
|
|
2805
3515
|
// walks: primary → model-first TIERED pool (same model on other providers,
|
|
@@ -2807,13 +3517,25 @@ export class ChatCommand extends BaseCommand {
|
|
|
2807
3517
|
// reserve) → ranked placeholders → config fallback. Chat used to build a
|
|
2808
3518
|
// shallower list here, which is why it reached strictly fewer models than
|
|
2809
3519
|
// the orchestrator could.
|
|
3520
|
+
// P3 — TASK-LEVEL CONTINUITY (RC7). A continuation carries no signal about
|
|
3521
|
+
// its own task, so the router re-decides from scratch and can land on a model
|
|
3522
|
+
// that has never served it (the live `resume` degraded from a working Gemini
|
|
3523
|
+
// model to one that returned nothing). Offer the pair that DID serve this
|
|
3524
|
+
// task FIRST. It is pushed through the same `pushCandidate` gate as every
|
|
3525
|
+
// other candidate, so a dead key, an active exclusion or a registry block
|
|
3526
|
+
// simply drops it and the normal pool is used instead.
|
|
3527
|
+
if (opts?.routingText) {
|
|
3528
|
+
const recalled = this.taskContinuity.recall(this.lastTaskSignature);
|
|
3529
|
+
if (recalled)
|
|
3530
|
+
pushCandidate(recalled.provider, recalled.model);
|
|
3531
|
+
}
|
|
2810
3532
|
const pool = buildDeepFailoverPool(decision, {
|
|
2811
3533
|
taskDescription: message,
|
|
2812
3534
|
complexity: decision.complexity,
|
|
2813
3535
|
configManager: this.configManager,
|
|
2814
3536
|
});
|
|
2815
3537
|
for (const c of pool)
|
|
2816
|
-
pushCandidate(c.provider, c.model);
|
|
3538
|
+
pushCandidate(c.provider, c.model, c.score, c.scoreBasis);
|
|
2817
3539
|
// Unique provider list (what callers use for their own failover) — derived
|
|
2818
3540
|
// from the pair list so it stays consistent with what is actually tried.
|
|
2819
3541
|
const candidates = [...new Set(chatCandidates.map((c) => c.provider))];
|
|
@@ -2838,7 +3560,12 @@ export class ChatCommand extends BaseCommand {
|
|
|
2838
3560
|
task: message,
|
|
2839
3561
|
verifyOnDemand: isMaxCapability(this.configManager),
|
|
2840
3562
|
})).model;
|
|
2841
|
-
// Record the actually-used route for the dashboard audit trail
|
|
3563
|
+
// Record the actually-used route for the dashboard audit trail.
|
|
3564
|
+
// B2-a — the score belongs to THE PAIR THIS ROW NAMES. `decision.score`
|
|
3565
|
+
// describes the router's own pick; a walked alternate has its own score
|
|
3566
|
+
// (`candidate.score`, a model-level measurement from the tiered pool) or
|
|
3567
|
+
// none at all (an ordering placeholder / a recalled pair). Writing the
|
|
3568
|
+
// decision's number here is what made a walk look like a tie.
|
|
2842
3569
|
recordRoutingDecision({
|
|
2843
3570
|
source: 'chat',
|
|
2844
3571
|
agentType: 'chat',
|
|
@@ -2846,7 +3573,9 @@ export class ChatCommand extends BaseCommand {
|
|
|
2846
3573
|
complexity: decision.complexity,
|
|
2847
3574
|
provider: candidate.provider,
|
|
2848
3575
|
model,
|
|
2849
|
-
|
|
3576
|
+
...(candidate.scoreBasis
|
|
3577
|
+
? { score: candidate.score, scoreBasis: candidate.scoreBasis }
|
|
3578
|
+
: {}),
|
|
2850
3579
|
agenticCapable: isAgenticCapableModel(model, candidate.provider),
|
|
2851
3580
|
overrideReason: decision.overrideReason,
|
|
2852
3581
|
...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),
|
|
@@ -2879,6 +3608,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
2879
3608
|
// over).
|
|
2880
3609
|
const usableProvider = [decision.provider, ...decision.ranked.map((r) => r.provider)]
|
|
2881
3610
|
.find((p) => !isActiveExclusion(p) && !registryBlocked.has(p)) || decision.provider;
|
|
3611
|
+
// B2-a — this row names `decision.model` possibly on a DIFFERENT provider
|
|
3612
|
+
// than the decision's (`usableProvider`), so the decision's composite does
|
|
3613
|
+
// not describe it. The pair was never ranked as a whole; record it bare
|
|
3614
|
+
// rather than attaching a number that belongs to another provider.
|
|
2882
3615
|
recordRoutingDecision({
|
|
2883
3616
|
source: 'chat',
|
|
2884
3617
|
agentType: 'chat',
|
|
@@ -2886,7 +3619,6 @@ export class ChatCommand extends BaseCommand {
|
|
|
2886
3619
|
complexity: decision.complexity,
|
|
2887
3620
|
provider: usableProvider,
|
|
2888
3621
|
model: decision.model,
|
|
2889
|
-
score: decision.score,
|
|
2890
3622
|
agenticCapable: isAgenticCapableModel(decision.model, usableProvider),
|
|
2891
3623
|
overrideReason: decision.overrideReason,
|
|
2892
3624
|
...(opts?.fallbackFrom ? { fallbackFrom: opts.fallbackFrom } : {}),
|