@duckcodeailabs/dql-cli 1.14.1 → 1.14.3-rc.1

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.
Files changed (125) hide show
  1. package/dist/args.d.ts +4 -0
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +16 -0
  4. package/dist/args.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/{AgentLogPage-BPz-UWFh.js → AgentLogPage-Jbyb4so-.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-BEl53WA_.js → AiBuildDialog-Du07X1qO.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-B4yfGTTZ.js → AiBuildResult-xKtGthWv.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-CSZAAvuD.js → AiSidePanel-CE_L04dK.js} +1 -1
  9. package/dist/assets/dql-notebook/assets/AnalyticsHome-CiUE10uF.js +6 -0
  10. package/dist/assets/dql-notebook/assets/{AppsView-DQOwU9Cg.js → AppsView-DlNaTuwD.js} +30 -35
  11. package/dist/assets/dql-notebook/assets/AskObservabilityPage-DracZPI9.js +1 -0
  12. package/dist/assets/dql-notebook/assets/AskTracePage-CSMoMw9a.js +8 -0
  13. package/dist/assets/dql-notebook/assets/{BlockStudio-B4ap0GdY.js → BlockStudio-DQS5Hcq9.js} +9 -14
  14. package/dist/assets/dql-notebook/assets/BusinessArtifactView-CqV4i9l_.js +1 -0
  15. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-D72byb2g.js → DbtFirstModelingPage-JKhB_Y2H.js} +3 -3
  16. package/dist/assets/dql-notebook/assets/{GitPage-lQb1uXH2.js → GitPage-DjaNp0o3.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{GlobalAiRail-CVECf6Xj.js → GlobalAiRail-CsV3zlc8.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{GovernedContextPage-trOyMCY6.js → GovernedContextPage-uXUHUOIJ.js} +3 -3
  19. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D8hLS5lE.js → HelpDocsPage-Hbh9IeYB.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{HomePage-eIkfBIep.js → HomePage-CRRl-R_v.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/LineageDAG-OMUP2sbl.js +1 -0
  22. package/dist/assets/dql-notebook/assets/LineageDetailView-C964BLuV.js +1 -0
  23. package/dist/assets/dql-notebook/assets/LineageDrawer-CzY0nfpD.js +1 -0
  24. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-CviIf8PN.js → LineagePathBreadcrumb-0Gi-Fm1B.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/MiniLineageGraph-wn0wLc9m.js +1 -0
  26. package/dist/assets/dql-notebook/assets/{NewBlockModal-DbCQg-pj.js → NewBlockModal-kMAzk91d.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{NewNotebookModal-5Vp6XiuK.js → NewNotebookModal-D_cWd_6M.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{NotebookEditor-DjqJS44s.js → NotebookEditor-Bax0lxaz.js} +25 -30
  29. package/dist/assets/dql-notebook/assets/{ReadinessPage-BHGrC5ho.js → ReadinessPage-DEUIXQQY.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{SetupOnboarding-BfS9Tdx-.js → SetupOnboarding-CyE-Ex41.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{SkillsPage-BFH21wSj.js → SkillsPage-QgvKGPxE.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{TrustBadge-zm6g_SxZ.js → TrustBadge-D5CGp0cu.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BjsTE6yf.js +89 -0
  34. package/dist/assets/dql-notebook/assets/{answer-to-notebook-DPhxIEzF.js → answer-to-notebook-tACdCsTV.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{arrow-left-DNEb86Xc.js → arrow-left-CF6wxXU5.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{arrow-right-DpPWbwaD.js → arrow-right-C7G3M_S8.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{book-open-text-D7s5jo4X.js → book-open-text-DDBO8G7L.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/chevron-left-DxajrX2v.js +6 -0
  39. package/dist/assets/dql-notebook/assets/{circle-x-Db3dXKg7.js → circle-x-HBvXjnT6.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/clock-3-CJIdsPLt.js +6 -0
  41. package/dist/assets/dql-notebook/assets/dagre.esm-B6nvU4OB.js +1 -0
  42. package/dist/assets/dql-notebook/assets/{external-link-BxwXitO_.js → external-link-WciAU8Bu.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{grip-vertical-Dt4lkWRi.js → grip-vertical-DVu7ok6x.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/index-B_kaoARS.css +1 -0
  45. package/dist/assets/dql-notebook/assets/{index-DKo-bwNw.js → index-D3wnucQC.js} +133 -128
  46. package/dist/assets/dql-notebook/assets/{link-2-Dfo2P6wi.js → link-2-C6uQx50X.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{list-tree-DiTmIWAL.js → list-tree-DapsIuQB.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{minimize-2-CMTAkPzL.js → minimize-2-BlVoipnT.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{panel-right-open-DwYr7FW4.js → panel-right-open-DcnKWxfx.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{play-BXhHYQ4x.js → play-CNuRuaAs.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{rotate-ccw-BNi6F8pl.js → rotate-ccw-B2p953Zh.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{semantic-fields-CNOGysAy.js → semantic-fields-2fWgkTJc.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{sliders-horizontal-Ec5MUMUW.js → sliders-horizontal-DwRTu1w4.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{star-B9leDkp_.js → star-Cbaj4F3i.js} +1 -1
  55. package/dist/assets/dql-notebook/assets/style-NlN9F6JE.js +23 -0
  56. package/dist/assets/dql-notebook/assets/{triangle-alert-BefTYCzx.js → triangle-alert-BPHvH1wA.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{upload-SPiOM2tQ.js → upload-DIqK0KE1.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-C4foXeiQ.js → usePersistedAgentThreadId-DpQXOEDw.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{user-round-comGmyw-.js → user-round-DukNupB_.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{wand-sparkles-BffR4dF8.js → wand-sparkles-CAwLX0b9.js} +1 -1
  61. package/dist/assets/dql-notebook/assets/{workflow-ChmPTEzH.js → workflow-bOT-NqdO.js} +1 -1
  62. package/dist/assets/dql-notebook/assets/{wrench-DovaG_ze.js → wrench-1m0_kcza.js} +1 -1
  63. package/dist/assets/dql-notebook/assets/{x-nRx91AgW.js → x-fp82BETM.js} +1 -1
  64. package/dist/assets/dql-notebook/index.html +2 -2
  65. package/dist/commands/agent-eval-cassette.d.ts +97 -6
  66. package/dist/commands/agent-eval-cassette.d.ts.map +1 -1
  67. package/dist/commands/agent-eval-cassette.js +165 -22
  68. package/dist/commands/agent-eval-cassette.js.map +1 -1
  69. package/dist/commands/agent-eval-runtime.d.ts +23 -2
  70. package/dist/commands/agent-eval-runtime.d.ts.map +1 -1
  71. package/dist/commands/agent-eval-runtime.js +19 -2
  72. package/dist/commands/agent-eval-runtime.js.map +1 -1
  73. package/dist/commands/agent-trace.d.ts +3 -0
  74. package/dist/commands/agent-trace.d.ts.map +1 -0
  75. package/dist/commands/agent-trace.js +169 -0
  76. package/dist/commands/agent-trace.js.map +1 -0
  77. package/dist/commands/agent.d.ts +37 -5
  78. package/dist/commands/agent.d.ts.map +1 -1
  79. package/dist/commands/agent.js +534 -84
  80. package/dist/commands/agent.js.map +1 -1
  81. package/dist/commands/compile.d.ts +13 -1
  82. package/dist/commands/compile.d.ts.map +1 -1
  83. package/dist/commands/compile.js +36 -4
  84. package/dist/commands/compile.js.map +1 -1
  85. package/dist/commands/notebook.d.ts +2 -0
  86. package/dist/commands/notebook.d.ts.map +1 -1
  87. package/dist/commands/notebook.js +4 -0
  88. package/dist/commands/notebook.js.map +1 -1
  89. package/dist/commands/sync.d.ts.map +1 -1
  90. package/dist/commands/sync.js +11 -3
  91. package/dist/commands/sync.js.map +1 -1
  92. package/dist/index.js +2 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/llm/providers/dql-agent-provider.d.ts +29 -2
  95. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  96. package/dist/llm/providers/dql-agent-provider.js +1008 -144
  97. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  98. package/dist/llm/types.d.ts +48 -1
  99. package/dist/llm/types.d.ts.map +1 -1
  100. package/dist/local-runtime.d.ts +271 -33
  101. package/dist/local-runtime.d.ts.map +1 -1
  102. package/dist/local-runtime.js +3658 -546
  103. package/dist/local-runtime.js.map +1 -1
  104. package/dist/package.json +10 -10
  105. package/dist/providers/oauth/claude-oauth.d.ts.map +1 -1
  106. package/dist/providers/oauth/claude-oauth.js +52 -18
  107. package/dist/providers/oauth/claude-oauth.js.map +1 -1
  108. package/dist/providers/oauth/codex-oauth.d.ts.map +1 -1
  109. package/dist/providers/oauth/codex-oauth.js +72 -44
  110. package/dist/providers/oauth/codex-oauth.js.map +1 -1
  111. package/dist/providers/subscription-cli.d.ts +20 -0
  112. package/dist/providers/subscription-cli.d.ts.map +1 -1
  113. package/dist/providers/subscription-cli.js +69 -11
  114. package/dist/providers/subscription-cli.js.map +1 -1
  115. package/package.json +10 -10
  116. package/dist/assets/dql-notebook/assets/AnalyticsHome-D5P6Ujwi.js +0 -6
  117. package/dist/assets/dql-notebook/assets/BusinessArtifactView-BIfNI-0S.js +0 -1
  118. package/dist/assets/dql-notebook/assets/LineageDAG-CSqcbDrE.js +0 -1
  119. package/dist/assets/dql-notebook/assets/LineageDetailView-DJZZjZu-.js +0 -1
  120. package/dist/assets/dql-notebook/assets/LineageDrawer-BcIipIc3.js +0 -1
  121. package/dist/assets/dql-notebook/assets/MiniLineageGraph-vH_MY_Ju.js +0 -1
  122. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel--oxmjlgr.js +0 -88
  123. package/dist/assets/dql-notebook/assets/dagre.esm-C7pppQ1a.js +0 -23
  124. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +0 -1
  125. /package/dist/assets/dql-notebook/assets/{dagre-BZV40eAE.css → style-BZV40eAE.css} +0 -0
@@ -1,9 +1,10 @@
1
- import { deadlineScale, planAnalystTurn, rerankCandidates } from '@duckcodeailabs/dql-agent';
1
+ import { classifyProviderFailure, deadlineScale, planAnalystTurn, attachAskTraceObserverV1, analyticalErrorDetail, rerankCandidates, askTraceObserverForV1, } from '@duckcodeailabs/dql-agent';
2
2
  import { buildAnalystLoopTools } from '../analyst-loop-tools.js';
3
- import { ClaudeProvider, KGStore, MemoryStore, defaultKgPath, defaultMemoryPath, GeminiProvider, loadAgentSemanticLayer, OllamaProvider, OpenAIProvider, answer, buildAnalysisQuestionPlan, buildLocalContextPack, contextRetrievalBudgetForQuestion, ensureAgentProjectReady, isLikelyClarificationReply, isTrustedConversationTurn, createProviderDispatchEgressReceipt, createProviderEgressReceipt, assertProviderPayloadAllowed, prepareProviderContextForDispatch, prepareProviderWireEnvelopeForDispatch, prepareServerOwnedProviderSchemaContext, projectEmbeddingProvider, answerAgentic, qualifyAuthorizationReferences, createAnalystLaneHandler, parseProposal, renderContextValidationRefusalForUser, validateSqlAgainstLocalContext, resolveOrchestratorPolicy, } from '@duckcodeailabs/dql-agent';
4
- import { CassetteStore, resolveCassetteModeFromEnv, withCassette } from '../../commands/agent-eval-cassette.js';
5
- import { buildManifest, normalizeDqlArtifactReference, resolveDbtManifestPath } from '@duckcodeailabs/dql-core';
6
- import { existsSync, readFileSync } from 'node:fs';
3
+ import { ClaudeProvider, KGStore, MemoryStore, defaultKgPath, defaultMemoryPath, GeminiProvider, loadAgentSemanticLayer, OllamaProvider, OpenAIProvider, answer, buildAnalysisQuestionPlan, buildLocalContextPack, contextRetrievalBudgetForQuestion, ensureAgentProjectReady, isLikelyClarificationReply, isTrustedConversationTurn, createProviderDispatchEgressReceipt, createProviderEgressReceipt, assertProviderPayloadAllowed, prepareProviderContextForDispatch, prepareProviderWireEnvelopeForDispatch, prepareServerOwnedProviderSchemaContext, projectEmbeddingProvider, answerAgentic, qualifyAuthorizationReferences, scopeContextPackToExploratoryCandidateClosure, createAnalystLaneHandler, parseProposal, renderContextValidationRefusalForUser, validateSqlAgainstLocalContext, resolveOrchestratorPolicy, } from '@duckcodeailabs/dql-agent';
4
+ import { CassetteStore, evalCassetteCanonicalizationV2, resolveCassetteModeFromEnv, withCassette, } from '../../commands/agent-eval-cassette.js';
5
+ import { buildManifest, normalizeDqlArtifactReference, resolveDbtManifestPath, } from '@duckcodeailabs/dql-core';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
8
  import { join } from 'node:path';
8
9
  import { buildAnswerLoopTools, createGroundingContextExpander } from '../answer-loop-tools.js';
9
10
  import { getSemanticRuntimeStatus } from '../../semantic-runtime.js';
@@ -12,6 +13,17 @@ import { getEffectiveProviderConfig } from '../../settings/provider-settings.js'
12
13
  import { ClaudeCodeCliProvider, CodexCliProvider } from '../../providers/subscription-cli.js';
13
14
  import { ClaudeOAuthProvider, claudeOAuthConnected } from '../../providers/oauth/claude-oauth.js';
14
15
  import { CodexOAuthProvider, codexOAuthConnected } from '../../providers/oauth/codex-oauth.js';
16
+ /**
17
+ * The local runtime rejects a missing connection before it hands a statement
18
+ * to a connector. Keep this check structural: parsing a user-facing message
19
+ * here would let an unrelated warehouse failure disappear from SQL telemetry.
20
+ */
21
+ function isPreSqlConnectionConfigurationError(error) {
22
+ const detail = analyticalErrorDetail(error);
23
+ return detail?.origin === 'host'
24
+ && detail.stage === 'execute'
25
+ && detail.code === 'connection_not_configured';
26
+ }
15
27
  const SPECS = {
16
28
  anthropic: {
17
29
  label: 'Anthropic Claude',
@@ -75,6 +87,136 @@ const SPECS = {
75
87
  },
76
88
  },
77
89
  };
90
+ /**
91
+ * Capture a content-free provider failure at the closest boundary. Local
92
+ * runtime may later choose a user-facing headline, but must not need raw
93
+ * provider errors or URLs to reconstruct the cause.
94
+ */
95
+ function providerBoundaryDiagnostic(input) {
96
+ const config = getEffectiveProviderConfig(input.projectRoot, input.providerId);
97
+ const message = input.error instanceof Error ? input.error.message : String(input.error ?? 'provider readiness failed');
98
+ const code = input.code
99
+ ?? (input.error && typeof input.error === 'object' ? String(input.error.code ?? '') : '');
100
+ const fingerprint = (value) => value?.trim()
101
+ ? `sha256:${createHash('sha256').update(value.trim()).digest('hex')}`
102
+ : undefined;
103
+ let origin;
104
+ if (config.baseUrl) {
105
+ try {
106
+ origin = new URL(config.baseUrl).origin;
107
+ }
108
+ catch {
109
+ // The full malformed URL never leaves this function; its fingerprint is
110
+ // still a useful support correlation key without disclosing content.
111
+ origin = config.baseUrl;
112
+ }
113
+ }
114
+ return classifyProviderFailure({
115
+ code,
116
+ message,
117
+ phase: input.phase,
118
+ providerFingerprint: fingerprint(input.providerId),
119
+ modelFingerprint: fingerprint(config.model),
120
+ baseOriginFingerprint: fingerprint(origin),
121
+ });
122
+ }
123
+ /** One-way runtime correlation only; never persist the provider/model string. */
124
+ function runtimeFingerprint(value) {
125
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
126
+ }
127
+ /**
128
+ * Tool names are executable contract identifiers, not user/model supplied
129
+ * values. Keep even that narrow surface allowlisted so an unexpected provider
130
+ * payload cannot turn a trace into a metadata side channel.
131
+ */
132
+ const TRACEABLE_ASK_TOOL_KINDS = new Set([
133
+ 'check_compatibility',
134
+ 'compile_resolved_analytical_plan',
135
+ 'compile_semantic_query',
136
+ 'describe_notebook_dataset',
137
+ 'execute_local_analysis',
138
+ 'explain_metric',
139
+ 'get_table_schema',
140
+ 'list_notebook_datasets',
141
+ 'preview_query',
142
+ 'propose_cross_source_join',
143
+ 'query_semantic_model',
144
+ 'sample_notebook_dataset',
145
+ 'scan_manifest',
146
+ 'search_metadata',
147
+ 'search_project_files',
148
+ 'search_semantic_layer',
149
+ 'search_values',
150
+ 'validate_sql',
151
+ ]);
152
+ const ASK_TRACE_TOOL_CALLBACK = Symbol('dql.askTraceToolCallback');
153
+ /**
154
+ * A same-provider transport retry is minted only by this physical runner after
155
+ * it observed a typed transient failure. The public option field is carried
156
+ * to providers for receipt correlation, but it is not itself authority to
157
+ * borrow an earlier receipt.
158
+ */
159
+ const RUNNER_OWNED_RETRY_LINEAGE = Symbol('dql.runnerOwnedRetryLineage');
160
+ function frozenExploratoryRepairAuthorityForRequest(req) {
161
+ const plan = req.resolvedAnalyticalPlan;
162
+ return req.orchestrationMode !== 'research'
163
+ && req.selectedCascadeTier === 'exploratory_sql'
164
+ && plan?.mode === 'authoritative'
165
+ && plan.capability === 'bounded_exploration'
166
+ && Boolean(plan.planId && plan.fingerprint && plan.snapshotId)
167
+ && (plan.sourceRelationIds?.length ?? 0) > 0
168
+ && Boolean(req.generatedProposalTargetFingerprint
169
+ && req.prepareExploratorySqlExecution
170
+ && req.executeAgenticGeneratedSql);
171
+ }
172
+ function repairAuthorityAdmissionError() {
173
+ return Object.assign(new Error('Repair transport admission denied because this ordinary Ask has no matching frozen exploratory repair authority.'), { code: 'PROVIDER_REPAIR_AUTHORITY_ADMISSION_DENIED' });
174
+ }
175
+ function retryLineageAdmissionError() {
176
+ return Object.assign(new Error('Provider retry admission denied because retry lineage was not minted by this runner after a transient same-provider failure.'), { code: 'PROVIDER_RETRY_LINEAGE_ADMISSION_DENIED' });
177
+ }
178
+ function recordPhysicalToolCallTrace(observer, event, attemptIndex) {
179
+ if (!observer.enabled)
180
+ return;
181
+ const now = Date.now();
182
+ const duration = Math.max(0, Math.min(86_400_000, event.durationMs ?? 0));
183
+ const startedAt = new Date(now - duration).toISOString();
184
+ const completedAt = new Date(now).toISOString();
185
+ const call = {
186
+ version: 1,
187
+ toolCallId: `tool-${attemptIndex}`,
188
+ toolKind: TRACEABLE_ASK_TOOL_KINDS.has(event.name) ? event.name : 'unknown_tool',
189
+ attemptIndex,
190
+ ...(event.isError ? { safeErrorCode: 'tool_error' } : {}),
191
+ };
192
+ const span = observer.startSpan({
193
+ name: 'tool.call',
194
+ stage: 'tool',
195
+ startedAt,
196
+ payload: { kind: 'tool', call },
197
+ reasonCode: 'started',
198
+ });
199
+ observer.finishSpan(span, {
200
+ completedAt,
201
+ outcome: event.isError ? 'error' : 'ok',
202
+ reasonCode: event.isError ? 'tool_failure' : 'completed',
203
+ payload: { kind: 'tool', call },
204
+ });
205
+ }
206
+ function createAskTraceToolCallback(observer) {
207
+ let attemptIndex = 0;
208
+ const callback = (event) => {
209
+ recordPhysicalToolCallTrace(observer, event, ++attemptIndex);
210
+ };
211
+ Object.defineProperty(callback, ASK_TRACE_TOOL_CALLBACK, {
212
+ value: true,
213
+ enumerable: false,
214
+ });
215
+ return callback;
216
+ }
217
+ function isAskTraceToolCallback(callback) {
218
+ return Boolean(callback?.[ASK_TRACE_TOOL_CALLBACK]);
219
+ }
78
220
  function createCertifiedFitConfirmation(provider, signal) {
79
221
  return async ({ question, questionPlan, block, fit }) => {
80
222
  const response = await provider.generate([
@@ -228,6 +370,40 @@ function stringArray(value) {
228
370
  function stringValue(value) {
229
371
  return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
230
372
  }
373
+ /**
374
+ * A precomputed exploratory pack is an optimization/witness only. The runner
375
+ * independently derives the pack from the immutable router-selected IDs and
376
+ * accepts the witness only when its snapshot, source fingerprint, physical
377
+ * relations, and metadata object set are exactly the same. This prevents a
378
+ * caller from replacing a safe same-snapshot closure with a broader one.
379
+ */
380
+ function exploratoryClosureMatches(derived, witness) {
381
+ if (!derived)
382
+ return false;
383
+ if (derived.knowledgeLens.snapshotId !== witness.knowledgeLens.snapshotId)
384
+ return false;
385
+ if (derived.freshness.fingerprint !== witness.freshness.fingerprint)
386
+ return false;
387
+ const normalize = (value) => value
388
+ .trim()
389
+ .split('.')
390
+ .map((part) => part.trim().replace(/^["`\[]|["`\]]$/g, '').toLowerCase())
391
+ .filter(Boolean)
392
+ .join('.');
393
+ const sameSet = (left, right) => {
394
+ if (left.length !== right.length)
395
+ return false;
396
+ const rightSet = new Set(right);
397
+ return left.every((value) => rightSet.has(value));
398
+ };
399
+ const derivedRelations = [...new Set(derived.allowedSqlContext.relations.map((relation) => normalize(relation.relation)))].sort();
400
+ const witnessRelations = [...new Set(witness.allowedSqlContext.relations.map((relation) => normalize(relation.relation)))].sort();
401
+ if (!sameSet(derivedRelations, witnessRelations))
402
+ return false;
403
+ const derivedObjects = [...new Set(derived.objects.map((object) => object.objectKey))].sort();
404
+ const witnessObjects = [...new Set(witness.objects.map((object) => object.objectKey))].sort();
405
+ return sameSet(derivedObjects, witnessObjects);
406
+ }
231
407
  function truncateForFitPrompt(value, max) {
232
408
  if (!value)
233
409
  return undefined;
@@ -279,16 +455,27 @@ function emitProposalFromText(text, emit) {
279
455
  * this one (local-runtime.ts:164), so reaching back would close an import cycle
280
456
  * through a 34k-line file. A few lines of JSON reading is the cheaper trade.
281
457
  *
282
- * Cached per project root because this runs on every turn and the answer is a
283
- * migration setting, not live state. A malformed file resolves to `null`, which
284
- * `resolveOrchestratorPolicy` turns into `legacy` a broken config must not
285
- * route real questions onto an unproven path.
458
+ * Cached by a lightweight file fingerprint, not forever. Settings writes must
459
+ * take effect for the next Ask without requiring a notebook-server restart.
460
+ * A malformed file resolves to `null`, which `resolveOrchestratorPolicy` turns
461
+ * into `legacy` — a broken config must not route real questions onto an
462
+ * unproven path.
286
463
  */
287
464
  const agentConfigCache = new Map();
465
+ function agentConfigFingerprint(projectRoot) {
466
+ try {
467
+ const stats = statSync(join(projectRoot, 'dql.config.json'));
468
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
469
+ }
470
+ catch {
471
+ return 'missing';
472
+ }
473
+ }
288
474
  function readAgentConfig(projectRoot) {
475
+ const fingerprint = agentConfigFingerprint(projectRoot);
289
476
  const cached = agentConfigCache.get(projectRoot);
290
- if (cached !== undefined)
291
- return cached;
477
+ if (cached && cached.fingerprint === fingerprint)
478
+ return cached.value;
292
479
  let resolved = null;
293
480
  try {
294
481
  const raw = readFileSync(join(projectRoot, 'dql.config.json'), 'utf-8');
@@ -300,7 +487,7 @@ function readAgentConfig(projectRoot) {
300
487
  catch {
301
488
  resolved = null;
302
489
  }
303
- agentConfigCache.set(projectRoot, resolved);
490
+ agentConfigCache.set(projectRoot, { fingerprint, value: resolved });
304
491
  return resolved;
305
492
  }
306
493
  function readOrchestratorConfig(projectRoot) {
@@ -325,10 +512,15 @@ function readOrchestratorConfig(projectRoot) {
325
512
  * measured on ollama, where all three agentic dispatches planned successfully
326
513
  * and then died at "soft target elapsed before this provider dispatch could
327
514
  * start". Its payoff is a legible trace, and `onStep` is not wired to SSE yet,
328
- * so today it buys nothing a user can see. Opt in with
329
- * `agent.orchestrator.turnPlanning: true` once streaming lands.
515
+ * so today it buys nothing a user can see. Ordinary Ask already has a
516
+ * candidate-ID meaning call that produces the host-owned analytical plan, so
517
+ * it must never spend an extra provider transport on turn planning even when
518
+ * a stale local config enables it. Research remains the only explicit lane
519
+ * that may opt in to a separate plan stage.
330
520
  */
331
- function turnPlanningEnabled(projectRoot) {
521
+ function turnPlanningEnabled(projectRoot, orchestrationMode) {
522
+ if (orchestrationMode !== 'research')
523
+ return false;
332
524
  const orchestrator = readAgentConfig(projectRoot)?.orchestrator;
333
525
  if (!orchestrator || typeof orchestrator !== 'object')
334
526
  return false;
@@ -355,11 +547,43 @@ function valueLookupEnabled(projectRoot) {
355
547
  function agenticLaneForRequest(req) {
356
548
  return req.orchestrationMode === 'research' ? 'research' : 'generated';
357
549
  }
358
- export function applyEvalCassette(provider) {
550
+ export function applyEvalCassette(provider, projectRoot) {
359
551
  const dir = process.env.DQL_EVAL_CASSETTE_DIR;
360
552
  if (!dir)
361
553
  return provider;
362
- return withCassette(provider, new CassetteStore(dir), resolveCassetteModeFromEnv(process.env));
554
+ return withCassette(provider, new CassetteStore(dir), resolveCassetteModeFromEnv(process.env), evalCassetteCanonicalizationV2(projectRoot));
555
+ }
556
+ /**
557
+ * Create a replay-only provider when the runtime is launched for an offline
558
+ * evaluation. This is intentionally unavailable outside explicit cassette
559
+ * replay: recording and live modes still require a configured real provider.
560
+ *
561
+ * The cassette's recorded provider identity is part of its key. Recover it
562
+ * from a single-provider cassette directory instead of borrowing a user's
563
+ * active provider or guessing from an API setting. The base provider cannot
564
+ * make a network call; replay misses remain CassetteMissError failures.
565
+ */
566
+ export function createEvalCassetteReplayProvider(projectRoot) {
567
+ const dir = process.env.DQL_EVAL_CASSETTE_DIR;
568
+ if (!dir || resolveCassetteModeFromEnv(process.env) !== 'replay')
569
+ return undefined;
570
+ const store = new CassetteStore(dir);
571
+ const providerNames = store.providerNames();
572
+ const providerName = providerNames.length === 1 ? asAgentProviderName(providerNames[0]) : undefined;
573
+ if (!providerName)
574
+ return undefined;
575
+ return withCassette({
576
+ name: providerName,
577
+ available: async () => true,
578
+ generate: async () => {
579
+ throw new Error('Eval cassette replay miss: no live provider is available.');
580
+ },
581
+ }, store, 'replay', evalCassetteCanonicalizationV2(projectRoot));
582
+ }
583
+ function asAgentProviderName(value) {
584
+ return value === 'claude' || value === 'openai' || value === 'gemini' || value === 'ollama'
585
+ ? value
586
+ : undefined;
363
587
  }
364
588
  /**
365
589
  * A raw text provider for planning calls that are not the answer itself.
@@ -373,7 +597,7 @@ export function createGovernedTextProvider(id, projectRoot) {
373
597
  if (!spec)
374
598
  return undefined;
375
599
  try {
376
- return applyEvalCassette(spec.create(projectRoot));
600
+ return applyEvalCassette(spec.create(projectRoot), projectRoot);
377
601
  }
378
602
  catch {
379
603
  return undefined;
@@ -383,9 +607,25 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
383
607
  return {
384
608
  async run(req, emit, signal) {
385
609
  const spec = SPECS[id];
386
- const rawProvider = applyEvalCassette(providerOverride ?? spec.create(req.projectRoot));
610
+ const rawProvider = applyEvalCassette(providerOverride ?? spec.create(req.projectRoot), req.projectRoot);
611
+ const askTrace = askTraceObserverForV1(req);
612
+ // A router-frozen exact certified block is a deterministic execution
613
+ // lane. It may not probe provider readiness merely because it shares the
614
+ // answer-loop adapter with provider-dependent routes. The flag is
615
+ // server-owned and the deterministic provider below throws if a future
616
+ // branch accidentally tries to generate text, so this cannot create a
617
+ // silent provider fallback.
618
+ const providerPreflightRequired = req.providerPreflightRequired !== false;
387
619
  const isResearch = req.orchestrationMode === 'research';
388
- const maxProviderDispatches = isResearch ? 8 : 4;
620
+ const frozenExploratoryRepairRoute = frozenExploratoryRepairAuthorityForRequest(req);
621
+ // Ordinary analytical Ask has one candidate-ID interpretation, one
622
+ // generation, and — only for an already frozen exploratory plan — one
623
+ // same-plan model-decline correction. The shared run ledger remains the
624
+ // authority across calls; this per-provider ceiling keeps un-ledgered
625
+ // direct callers in the same bounded shape. A legacy text-tool run is
626
+ // not an analytical repair route and retains its established three-tool
627
+ // plus final-response wrapper cap; it cannot acquire the repair phase.
628
+ const maxProviderDispatches = isResearch ? 8 : frozenExploratoryRepairRoute ? 3 : 4;
389
629
  const researchRowsOptIn = isResearch && req.researchResultRowsOptIn === true;
390
630
  const sharedDispatchEvidence = req.providerDispatchEvidenceSink;
391
631
  let providerRoundTrips = 0;
@@ -394,6 +634,11 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
394
634
  let pendingColumnCount = 0;
395
635
  let pendingCumulativeResultRowCount = 0;
396
636
  let pendingResearchPurpose = 'research_narration';
637
+ let physicalToolAttemptIndex = 0;
638
+ let physicalProviderAttemptIndex = 0;
639
+ let lastFailedProviderSpanId;
640
+ let physicalDispatchSequence = 0;
641
+ let lastAdmittedPhysicalDispatch;
397
642
  const providerEgressReceipts = [];
398
643
  const dispatchEvidence = (fallbackReason) => {
399
644
  const shared = sharedDispatchEvidence?.snapshot(fallbackReason);
@@ -408,106 +653,598 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
408
653
  fallbackReason,
409
654
  };
410
655
  };
656
+ /**
657
+ * Count a SQL call at the physical execution boundary, not merely when
658
+ * the answer loop asks the host to prepare one. A tagged missing
659
+ * connection fails before the connector sees SQL, so it must remain zero
660
+ * in the durable receipt and trace. Other rejected execution callbacks
661
+ * retain the historical count: they may have reached the warehouse and
662
+ * failed there.
663
+ */
664
+ const executeAtSqlBoundary = async (work) => {
665
+ try {
666
+ const result = await work();
667
+ sqlExecutions += 1;
668
+ return result;
669
+ }
670
+ catch (error) {
671
+ if (!isPreSqlConnectionConfigurationError(error))
672
+ sqlExecutions += 1;
673
+ throw error;
674
+ }
675
+ };
411
676
  signal.addEventListener('abort', () => {
412
677
  const reason = signal.reason;
413
678
  if (reason && typeof reason === 'object') {
414
679
  Object.assign(reason, { providerDispatchEvidence: dispatchEvidence('cancelled') });
415
680
  }
416
681
  }, { once: true });
417
- const withPhysicalDispatchObserver = (options) => ({
418
- ...(options ?? {}),
419
- // A tool-calling loop capped at two physical sends can make exactly one
420
- // tool call before it must answer, which is not enough to look something
421
- // up and then use it. The run-scoped ledger is the real guardrail.
422
- maxProviderDispatches,
423
- ...(sharedDispatchEvidence?.mayStartToolCall
424
- ? { mayStartToolCall: () => sharedDispatchEvidence.mayStartToolCall() }
425
- : {}),
426
- onProviderDispatch: (event) => {
427
- const purpose = isResearch ? pendingResearchPurpose : 'answer_generation';
428
- if (sharedDispatchEvidence) {
429
- const envelope = sharedDispatchEvidence.observe(event, {
430
- purpose,
431
- dispatchPhase: 'generation',
432
- optIn: pendingResultRowCount > 0 && researchRowsOptIn,
433
- serializedResultShape: {
434
- resultRowCount: pendingResultRowCount,
435
- columnCount: pendingColumnCount,
436
- },
437
- ...(pendingCumulativeResultRowCount > 0
438
- ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
439
- : {}),
440
- });
441
- pendingResultRowCount = 0;
442
- pendingColumnCount = 0;
443
- pendingCumulativeResultRowCount = 0;
444
- pendingResearchPurpose = 'research_narration';
445
- return envelope;
682
+ const providerAttemptPayload = (event, input) => {
683
+ const diagnostic = input.diagnostic;
684
+ const config = getEffectiveProviderConfig(req.projectRoot, id);
685
+ let baseOrigin;
686
+ if (config.baseUrl) {
687
+ try {
688
+ baseOrigin = new URL(config.baseUrl).origin;
446
689
  }
447
- // Fallback path only (CLI/MCP-direct runs carry no shared ledger).
448
- // Kept in step with `maxProviderDispatches` above so the same question
449
- // does not get a different budget depending on which surface asked it.
450
- if (!isResearch && providerRoundTrips >= 4) {
451
- throw Object.assign(new Error('Provider dispatch budget exhausted after four ordinary generation attempts.'), {
452
- code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED',
690
+ catch {
691
+ baseOrigin = config.baseUrl;
692
+ }
693
+ }
694
+ const model = event.model ?? config.model;
695
+ return {
696
+ version: 1,
697
+ phase: input.phase,
698
+ purpose: input.purpose,
699
+ physicalAttemptIndex: ++physicalProviderAttemptIndex,
700
+ providerFingerprint: diagnostic?.providerFingerprint ?? runtimeFingerprint(event.provider),
701
+ ...(diagnostic?.modelFingerprint || model ? { modelFingerprint: diagnostic?.modelFingerprint ?? runtimeFingerprint(model) } : {}),
702
+ ...(diagnostic?.baseOriginFingerprint || baseOrigin ? { baseOriginFingerprint: diagnostic?.baseOriginFingerprint ?? runtimeFingerprint(baseOrigin) } : {}),
703
+ ...(input.retryOfSpanId ? { retryOfSpanId: input.retryOfSpanId } : {}),
704
+ admission: input.admission,
705
+ ...(diagnostic?.httpStatusClass ? { httpStatusClass: diagnostic.httpStatusClass } : {}),
706
+ ...(diagnostic?.retryable !== undefined ? { retryable: diagnostic.retryable } : {}),
707
+ ...(diagnostic?.safeAction ? { safeAction: diagnostic.safeAction } : {}),
708
+ ...(diagnostic?.cause ? { cause: diagnostic.cause } : {}),
709
+ provenance: 'live',
710
+ };
711
+ };
712
+ const providerDiagnosticForTrace = (error, phase) => providerBoundaryDiagnostic({
713
+ providerId: id,
714
+ projectRoot: req.projectRoot,
715
+ phase,
716
+ error,
717
+ code: error && typeof error === 'object' ? String(error.code ?? '') : undefined,
718
+ });
719
+ /**
720
+ * The wrapper is the authoritative physical-dispatch boundary. Preserve
721
+ * a Research phase that the host explicitly set, but ordinary Ask only
722
+ * accepts the frozen-plan repair marker; every other Ask transport is
723
+ * answer generation.
724
+ */
725
+ const providerDispatchIdentity = (options) => {
726
+ const requestedPhase = options?.dispatchPhase;
727
+ const requestedPurpose = options?.egressPurpose;
728
+ // In an ordinary Ask, `repair` and `repair_sql` are a paired,
729
+ // server-owned capability. Preserve the requested repair identity in
730
+ // a denied trace so support can see what was refused, but do not admit
731
+ // it unless this runner captured the immutable exploratory RAP and
732
+ // the answer loop supplied the single-send repair shape.
733
+ const ordinaryRepairRequested = !isResearch
734
+ && (requestedPhase === 'repair' || requestedPurpose === 'repair_sql');
735
+ const ordinaryRepairAuthorized = ordinaryRepairRequested
736
+ && frozenExploratoryRepairRoute
737
+ && requestedPhase === 'repair'
738
+ && requestedPurpose === 'repair_sql'
739
+ && options?.maxProviderDispatches === 1
740
+ && options?.retryOfAttemptIndex === undefined;
741
+ const researchPhase = requestedPhase === 'classification'
742
+ || requestedPhase === 'meaning_resolution'
743
+ || requestedPhase === 'planning'
744
+ || requestedPhase === 'generation'
745
+ || requestedPhase === 'narration'
746
+ || requestedPhase === 'repair';
747
+ const dispatchPhase = isResearch && researchPhase
748
+ ? requestedPhase
749
+ : ordinaryRepairRequested
750
+ ? 'repair'
751
+ : 'generation';
752
+ const purpose = dispatchPhase === 'repair'
753
+ ? 'repair_sql'
754
+ : isResearch && (requestedPurpose === 'research_narration'
755
+ || requestedPurpose === 'research_tool'
756
+ || requestedPurpose === 'answer_generation')
757
+ ? requestedPurpose
758
+ : isResearch
759
+ ? pendingResearchPurpose
760
+ : 'answer_generation';
761
+ const retryRequested = options?.retryOfAttemptIndex !== undefined;
762
+ const runnerRetry = options?.[RUNNER_OWNED_RETRY_LINEAGE];
763
+ const retryAuthorized = retryRequested
764
+ && Boolean(runnerRetry
765
+ && runnerRetry.parentAttemptIndex === options?.retryOfAttemptIndex
766
+ && runnerRetry.phase === dispatchPhase
767
+ && runnerRetry.purpose === purpose);
768
+ return {
769
+ dispatchPhase,
770
+ purpose,
771
+ ordinaryRepairRequested,
772
+ ordinaryRepairAuthorized,
773
+ retryRequested,
774
+ retryAuthorized,
775
+ };
776
+ };
777
+ const withPhysicalDispatchObserver = (options) => {
778
+ // The answer loop can label exactly one frozen-plan correction as a
779
+ // repair. Do not let arbitrary provider options mint another phase:
780
+ // the local runner owns all other ordinary Ask dispatches as generation.
781
+ const identity = providerDispatchIdentity(options);
782
+ const { dispatchPhase, purpose, ordinaryRepairRequested, ordinaryRepairAuthorized, retryRequested, retryAuthorized, } = identity;
783
+ const requestedPhysicalCap = typeof options?.maxProviderDispatches === 'number'
784
+ && Number.isInteger(options.maxProviderDispatches)
785
+ && options.maxProviderDispatches > 0
786
+ ? options.maxProviderDispatches
787
+ : maxProviderDispatches;
788
+ // A repair response is one physical send even if a lower-level
789
+ // provider supports protocol retries. The frozen plan's one repair
790
+ // reservation must not turn into an unbounded transport loop.
791
+ const physicalDispatchCap = dispatchPhase === 'repair'
792
+ ? 1
793
+ : Math.min(maxProviderDispatches, requestedPhysicalCap);
794
+ const onToolCall = options?.onToolCall;
795
+ const onProviderDispatch = options?.onProviderDispatch;
796
+ const onProviderDispatchComplete = options?.onProviderDispatchComplete;
797
+ const onProviderDispatchRejected = options?.onProviderDispatchRejected;
798
+ const pending = new Map();
799
+ const keyForDispatch = (event) => `${event.provider}:${event.operation}:${event.attemptIndex}`;
800
+ // An admission failure can be surfaced both by a throwing admission
801
+ // callback and a provider's optional rejection callback. It is one
802
+ // unsent attempt, not two trace rows.
803
+ const admittedKeys = new Set();
804
+ const deniedKeys = new Set();
805
+ const finish = (entry, outcome, error, httpStatus) => {
806
+ if (!entry.spanId)
807
+ return;
808
+ if (outcome === 'ok') {
809
+ askTrace.finishSpan(entry.spanId, {
810
+ outcome: 'ok',
811
+ reasonCode: 'completed',
812
+ payload: { kind: 'provider', attempt: entry.attempt },
453
813
  });
814
+ return;
454
815
  }
455
- const envelope = prepareProviderWireEnvelopeForDispatch(rawProvider.name, event.envelope);
456
- assertProviderPayloadAllowed(envelope, {
457
- allowResultRows: false,
458
- maxResultRows: 0,
459
- purpose,
816
+ const diagnostic = providerDiagnosticForTrace(error ?? (typeof httpStatus === 'number' ? Object.assign(new Error(`HTTP ${httpStatus}`), { code: `HTTP_${httpStatus}` }) : undefined), dispatchPhase);
817
+ const failureAttempt = {
818
+ ...entry.attempt,
819
+ ...(diagnostic.httpStatusClass ? { httpStatusClass: diagnostic.httpStatusClass } : {}),
820
+ retryable: diagnostic.retryable,
821
+ safeAction: diagnostic.safeAction,
822
+ cause: outcome === 'cancelled' ? 'cancelled' : diagnostic.cause,
823
+ };
824
+ askTrace.finishSpan(entry.spanId, {
825
+ outcome: outcome === 'cancelled' ? 'cancelled' : 'error',
826
+ reasonCode: outcome === 'cancelled' ? 'cancelled' : 'provider_failure',
827
+ payload: { kind: 'provider', attempt: failureAttempt },
460
828
  });
461
- providerRoundTrips += 1;
462
- providerEgressReceipts.push(createProviderDispatchEgressReceipt({
463
- purpose,
464
- dispatchPhase: 'generation',
465
- provider: rawProvider.name,
466
- permittedCategories: pendingResultRowCount > 0
467
- ? ['instructions', 'question', 'schema_metadata', 'governed_context', 'result_rows']
468
- : ['instructions', 'question', 'schema_metadata', 'governed_context'],
469
- optIn: pendingResultRowCount > 0 && researchRowsOptIn,
470
- envelope,
471
- serializedResultShape: {
472
- resultRowCount: pendingResultRowCount,
473
- columnCount: pendingColumnCount,
829
+ lastFailedProviderSpanId = entry.spanId;
830
+ };
831
+ const physicalOptions = {
832
+ ...(options ?? {}),
833
+ // The raw provider loop has the same outer ceiling as the local Ask
834
+ // contract. The run-scoped ledger is the authority for phase limits:
835
+ // one planning/generation transport plus, only when the answer loop
836
+ // presents a frozen exploratory repair marker, one repair transport.
837
+ maxProviderDispatches: physicalDispatchCap,
838
+ ...(sharedDispatchEvidence?.mayStartToolCall
839
+ ? { mayStartToolCall: () => sharedDispatchEvidence.mayStartToolCall() }
840
+ : {}),
841
+ onProviderDispatch: (event) => {
842
+ try {
843
+ // This is the last synchronous boundary before a native provider
844
+ // serializes bytes. A caller cannot mint an ordinary Ask repair
845
+ // merely by placing lifecycle labels in `ProviderToolLoopOptions`.
846
+ if (ordinaryRepairRequested && !ordinaryRepairAuthorized) {
847
+ throw repairAuthorityAdmissionError();
848
+ }
849
+ // Retry correlation is likewise host-owned. The shared ledger
850
+ // validates the parent receipt too, but this marker prevents a
851
+ // caller from claiming an arbitrary earlier attempt before that
852
+ // receipt check can admit a wire body.
853
+ if (retryRequested && !retryAuthorized) {
854
+ throw retryLineageAdmissionError();
855
+ }
856
+ if (sharedDispatchEvidence) {
857
+ const envelope = sharedDispatchEvidence.observe(event, {
858
+ purpose,
859
+ dispatchPhase,
860
+ optIn: pendingResultRowCount > 0 && researchRowsOptIn,
861
+ serializedResultShape: {
862
+ resultRowCount: pendingResultRowCount,
863
+ columnCount: pendingColumnCount,
864
+ },
865
+ ...(pendingCumulativeResultRowCount > 0
866
+ ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
867
+ : {}),
868
+ ...(options?.retryOfAttemptIndex !== undefined
869
+ ? { retryOfAttemptIndex: options.retryOfAttemptIndex }
870
+ : {}),
871
+ });
872
+ pendingResultRowCount = 0;
873
+ pendingColumnCount = 0;
874
+ pendingCumulativeResultRowCount = 0;
875
+ pendingResearchPurpose = 'research_narration';
876
+ const observedEnvelope = onProviderDispatch?.(event) ?? envelope;
877
+ const attempt = providerAttemptPayload(event, {
878
+ admission: 'admitted',
879
+ phase: dispatchPhase,
880
+ purpose,
881
+ retryOfSpanId: lastFailedProviderSpanId,
882
+ });
883
+ const spanId = askTrace.startSpan({ name: 'provider.attempt', stage: 'provider', reasonCode: 'started', payload: { kind: 'provider', attempt } });
884
+ const key = keyForDispatch(event);
885
+ lastAdmittedPhysicalDispatch = {
886
+ sequence: ++physicalDispatchSequence,
887
+ attemptIndex: event.attemptIndex,
888
+ phase: dispatchPhase,
889
+ purpose,
890
+ };
891
+ admittedKeys.add(key);
892
+ pending.set(key, [...(pending.get(key) ?? []), { spanId, attempt }]);
893
+ return observedEnvelope;
894
+ }
895
+ // Fallback path only (CLI/MCP-direct runs carry no shared ledger).
896
+ // Kept in step with `maxProviderDispatches` above so the same question
897
+ // does not get a different budget depending on which surface asked it.
898
+ if (!isResearch && providerRoundTrips >= physicalDispatchCap) {
899
+ throw Object.assign(new Error(`Provider dispatch budget exhausted after ${physicalDispatchCap} ordinary Ask attempts.`), {
900
+ code: 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED',
901
+ });
902
+ }
903
+ const envelope = prepareProviderWireEnvelopeForDispatch(rawProvider.name, event.envelope);
904
+ assertProviderPayloadAllowed(envelope, {
905
+ allowResultRows: false,
906
+ maxResultRows: 0,
907
+ purpose,
908
+ });
909
+ providerRoundTrips += 1;
910
+ providerEgressReceipts.push(createProviderDispatchEgressReceipt({
911
+ purpose,
912
+ dispatchPhase,
913
+ provider: rawProvider.name,
914
+ ...(options?.retryOfAttemptIndex !== undefined
915
+ ? { retryOfAttemptIndex: options.retryOfAttemptIndex }
916
+ : {}),
917
+ permittedCategories: pendingResultRowCount > 0
918
+ ? ['instructions', 'question', 'schema_metadata', 'governed_context', 'result_rows']
919
+ : ['instructions', 'question', 'schema_metadata', 'governed_context'],
920
+ optIn: pendingResultRowCount > 0 && researchRowsOptIn,
921
+ envelope,
922
+ serializedResultShape: {
923
+ resultRowCount: pendingResultRowCount,
924
+ columnCount: pendingColumnCount,
925
+ },
926
+ ...(pendingCumulativeResultRowCount > 0
927
+ ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
928
+ : {}),
929
+ }));
930
+ pendingResultRowCount = 0;
931
+ pendingColumnCount = 0;
932
+ pendingCumulativeResultRowCount = 0;
933
+ pendingResearchPurpose = 'research_narration';
934
+ const observedEnvelope = onProviderDispatch?.(event) ?? envelope;
935
+ const attempt = providerAttemptPayload(event, {
936
+ admission: 'admitted',
937
+ phase: dispatchPhase,
938
+ purpose,
939
+ retryOfSpanId: lastFailedProviderSpanId,
940
+ });
941
+ const spanId = askTrace.startSpan({ name: 'provider.attempt', stage: 'provider', reasonCode: 'started', payload: { kind: 'provider', attempt } });
942
+ const key = keyForDispatch(event);
943
+ lastAdmittedPhysicalDispatch = {
944
+ sequence: ++physicalDispatchSequence,
945
+ attemptIndex: event.attemptIndex,
946
+ phase: dispatchPhase,
947
+ purpose,
948
+ };
949
+ admittedKeys.add(key);
950
+ pending.set(key, [...(pending.get(key) ?? []), { spanId, attempt }]);
951
+ return observedEnvelope;
952
+ }
953
+ catch (error) {
954
+ // The send was denied before an HTTP attempt. Preserve that
955
+ // distinction so support does not misread a budget/admission guard
956
+ // as a provider outage.
957
+ const key = keyForDispatch(event);
958
+ if (!admittedKeys.has(key) && !deniedKeys.has(key)) {
959
+ deniedKeys.add(key);
960
+ const diagnostic = providerDiagnosticForTrace(error, dispatchPhase);
961
+ const attempt = providerAttemptPayload(event, {
962
+ admission: 'denied',
963
+ phase: dispatchPhase,
964
+ purpose,
965
+ diagnostic,
966
+ retryOfSpanId: lastFailedProviderSpanId,
967
+ });
968
+ const spanId = askTrace.startSpan({ name: 'provider.attempt', stage: 'provider', reasonCode: 'provider_failure', payload: { kind: 'provider', attempt } });
969
+ askTrace.finishSpan(spanId, {
970
+ outcome: 'denied',
971
+ reasonCode: 'provider_failure',
972
+ payload: { kind: 'provider', attempt },
973
+ });
974
+ lastFailedProviderSpanId = spanId;
975
+ }
976
+ try {
977
+ onProviderDispatchRejected?.({
978
+ provider: event.provider,
979
+ operation: event.operation,
980
+ attemptIndex: event.attemptIndex,
981
+ ...(event.model ? { model: event.model } : {}),
982
+ error,
983
+ });
984
+ }
985
+ catch {
986
+ // A source observer cannot turn a denied local admission into a
987
+ // provider send or a second failure record.
988
+ }
989
+ throw error;
990
+ }
991
+ },
992
+ onProviderDispatchComplete: (event) => {
993
+ const key = keyForDispatch(event);
994
+ const entries = pending.get(key) ?? [];
995
+ const entry = entries[0];
996
+ // A successful HTTP response or subscription child-process exit is
997
+ // merely a physical milestone. Keep the same span open until the
998
+ // parser/stream/result path settles; this prevents a malformed 200
999
+ // response from being recorded as a successful provider attempt.
1000
+ if (entry && event.outcome === 'ok' && (event.settlement === 'transport' || event.settlement === 'process')) {
1001
+ entry.attempt = {
1002
+ ...entry.attempt,
1003
+ ...(event.settlement === 'transport' ? { transportOutcome: 'ok' } : { processOutcome: 'ok' }),
1004
+ };
1005
+ }
1006
+ else {
1007
+ const closed = entries.shift();
1008
+ if (entries.length > 0)
1009
+ pending.set(key, entries);
1010
+ else
1011
+ pending.delete(key);
1012
+ if (closed)
1013
+ finish(closed, event.outcome, event.error, event.httpStatus);
1014
+ }
1015
+ onProviderDispatchComplete?.(event);
1016
+ },
1017
+ onProviderDispatchRejected: (event) => {
1018
+ // `prepareProviderHttpDispatch` rejected this before it could send
1019
+ // bytes. Keep it as a denied admission rather than pretending an
1020
+ // HTTP attempt happened (or losing dispatch-budget evidence).
1021
+ const key = keyForDispatch(event);
1022
+ if (!admittedKeys.has(key) && !deniedKeys.has(key)) {
1023
+ deniedKeys.add(key);
1024
+ const diagnostic = providerDiagnosticForTrace(event.error, dispatchPhase);
1025
+ const attempt = providerAttemptPayload(event, {
1026
+ admission: 'denied',
1027
+ phase: dispatchPhase,
1028
+ purpose,
1029
+ diagnostic,
1030
+ retryOfSpanId: lastFailedProviderSpanId,
1031
+ });
1032
+ const spanId = askTrace.startSpan({
1033
+ name: 'provider.attempt',
1034
+ stage: 'provider',
1035
+ reasonCode: 'provider_failure',
1036
+ payload: { kind: 'provider', attempt },
1037
+ });
1038
+ askTrace.finishSpan(spanId, {
1039
+ outcome: 'denied',
1040
+ reasonCode: 'provider_failure',
1041
+ payload: { kind: 'provider', attempt },
1042
+ });
1043
+ lastFailedProviderSpanId = spanId;
1044
+ }
1045
+ try {
1046
+ onProviderDispatchRejected?.(event);
1047
+ }
1048
+ catch { /* observer is fail-open */ }
1049
+ },
1050
+ onToolCall: (event) => {
1051
+ // Native providers call this callback at the physical tool boundary.
1052
+ // Text-protocol loops use the trace-marked callback they received
1053
+ // directly, so this avoids recording a single tool twice.
1054
+ if (!isAskTraceToolCallback(onToolCall)) {
1055
+ recordPhysicalToolCallTrace(askTrace, event, ++physicalToolAttemptIndex);
1056
+ }
1057
+ onToolCall?.(event);
1058
+ },
1059
+ };
1060
+ return {
1061
+ options: physicalOptions,
1062
+ settle: (outcome, error) => {
1063
+ for (const entries of pending.values()) {
1064
+ for (const entry of entries)
1065
+ finish(entry, outcome, error);
1066
+ }
1067
+ pending.clear();
1068
+ },
1069
+ };
1070
+ };
1071
+ // One retry only, on the SAME configured provider and only for failures
1072
+ // that are normally transient. The physical-dispatch observer remains in
1073
+ // the path for both attempts, so a retry cannot exceed the run-wide
1074
+ // dispatch budget or silently fail over to another provider.
1075
+ let transientRetryUsed = false;
1076
+ const mayRetrySameProvider = (error, identity, parent) => {
1077
+ // A frozen exploratory run deliberately reserves its third physical
1078
+ // dispatch for the same-plan SQL repair. A transient retry of meaning
1079
+ // or generation would consume that reservation, so it is forbidden.
1080
+ if (signal.aborted
1081
+ || transientRetryUsed
1082
+ || frozenExploratoryRepairRoute
1083
+ || identity.dispatchPhase === 'repair'
1084
+ || !parent
1085
+ || parent.phase !== identity.dispatchPhase
1086
+ || parent.purpose !== identity.purpose)
1087
+ return false;
1088
+ const diagnostic = providerDiagnosticForTrace(error, identity.dispatchPhase);
1089
+ return diagnostic.retryable && (diagnostic.cause === 'rate_limited'
1090
+ || diagnostic.cause === 'gateway'
1091
+ || diagnostic.cause === 'network'
1092
+ || diagnostic.cause === 'provider_timeout');
1093
+ };
1094
+ const retrySameProviderOnce = async (sourceOptions, operation) => {
1095
+ const identity = providerDispatchIdentity(sourceOptions);
1096
+ const dispatchSequenceBefore = physicalDispatchSequence;
1097
+ try {
1098
+ return await operation(sourceOptions);
1099
+ }
1100
+ catch (error) {
1101
+ const parent = lastAdmittedPhysicalDispatch?.sequence && lastAdmittedPhysicalDispatch.sequence > dispatchSequenceBefore
1102
+ ? lastAdmittedPhysicalDispatch
1103
+ : undefined;
1104
+ if (!mayRetrySameProvider(error, identity, parent))
1105
+ throw error;
1106
+ transientRetryUsed = true;
1107
+ emit({ kind: 'thinking', text: 'The configured AI provider had a transient error; retrying it once within this run budget.' });
1108
+ // The retry carries immutable phase/purpose/provider lineage. The
1109
+ // ledger re-validates the parent receipt before it admits any bytes.
1110
+ const retryOptions = {
1111
+ ...(sourceOptions ?? {}),
1112
+ dispatchPhase: identity.dispatchPhase,
1113
+ egressPurpose: identity.purpose,
1114
+ retryOfAttemptIndex: parent.attemptIndex,
1115
+ };
1116
+ Object.defineProperty(retryOptions, RUNNER_OWNED_RETRY_LINEAGE, {
1117
+ value: {
1118
+ parentAttemptIndex: parent.attemptIndex,
1119
+ phase: identity.dispatchPhase,
1120
+ purpose: identity.purpose,
474
1121
  },
475
- ...(pendingCumulativeResultRowCount > 0
476
- ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
477
- : {}),
478
- }));
479
- pendingResultRowCount = 0;
480
- pendingColumnCount = 0;
481
- pendingCumulativeResultRowCount = 0;
482
- pendingResearchPurpose = 'research_narration';
483
- return envelope;
484
- },
485
- });
1122
+ enumerable: false,
1123
+ });
1124
+ return operation(retryOptions);
1125
+ }
1126
+ };
1127
+ const invokePhysicalProvider = async (sourceOptions, invoke) => {
1128
+ const observed = withPhysicalDispatchObserver(sourceOptions);
1129
+ try {
1130
+ const result = await invoke(observed.options);
1131
+ // Built-in HTTP providers close each span via
1132
+ // `onProviderDispatchComplete`; this only closes a custom provider
1133
+ // that exposes admission but not the optional completion callback.
1134
+ observed.settle('ok');
1135
+ return result;
1136
+ }
1137
+ catch (error) {
1138
+ observed.settle(signal.aborted ? 'cancelled' : 'error', error);
1139
+ throw error;
1140
+ }
1141
+ };
486
1142
  const provider = {
487
1143
  name: rawProvider.name,
488
1144
  available: () => rawProvider.available(),
489
- generate: (...args) => {
490
- return rawProvider.generate(args[0], withPhysicalDispatchObserver(args[1]));
491
- },
1145
+ generate: (...args) => retrySameProviderOnce(args[1], (sourceOptions) => invokePhysicalProvider(sourceOptions, (options) => rawProvider.generate(args[0], options))),
492
1146
  ...(rawProvider.generateWithTools ? {
493
- generateWithTools: (...args) => {
494
- return rawProvider.generateWithTools(args[0], args[1], withPhysicalDispatchObserver(args[2]));
495
- },
1147
+ generateWithTools: (...args) => retrySameProviderOnce(args[2], (sourceOptions) => invokePhysicalProvider(sourceOptions, (options) => rawProvider.generateWithTools(args[0], args[1], options))),
496
1148
  } : {}),
497
1149
  ...(rawProvider.generateStream ? {
498
- generateStream: (...args) => {
499
- return rawProvider.generateStream(args[0], withPhysicalDispatchObserver(args[1]), args[2]);
500
- },
1150
+ generateStream: (...args) => invokePhysicalProvider(args[1], (options) => rawProvider.generateStream(args[0], options, args[2])),
501
1151
  } : {}),
502
1152
  };
503
- const available = await provider.available().catch(() => false);
504
- if (!available) {
505
- emit({ kind: 'error', message: `${spec.label} is not configured or reachable. ${spec.setup}` });
506
- return;
1153
+ if (providerPreflightRequired) {
1154
+ // Readiness is a real provider operation, not a proxy for a later
1155
+ // answer-loop invocation. Record its own outcome before a route can
1156
+ // depend on this provider, while keeping the observer wholly fail-open.
1157
+ const preflightSpan = askTrace.startSpan({
1158
+ name: 'provider.preflight',
1159
+ stage: 'provider',
1160
+ reasonCode: 'started',
1161
+ payload: {
1162
+ kind: 'provider',
1163
+ attempt: {
1164
+ version: 1,
1165
+ phase: 'preflight',
1166
+ physicalAttemptIndex: 0,
1167
+ providerFingerprint: runtimeFingerprint(rawProvider.name),
1168
+ readiness: 'unknown',
1169
+ admission: 'unknown',
1170
+ provenance: 'live',
1171
+ },
1172
+ },
1173
+ });
1174
+ let preflightError;
1175
+ let available = false;
1176
+ try {
1177
+ available = await provider.available();
1178
+ }
1179
+ catch (error) {
1180
+ preflightError = error;
1181
+ }
1182
+ if (!available) {
1183
+ const message = `${spec.label} is not configured or reachable. ${spec.setup}`;
1184
+ const diagnostic = providerBoundaryDiagnostic({
1185
+ providerId: id,
1186
+ projectRoot: req.projectRoot,
1187
+ phase: 'preflight',
1188
+ error: preflightError ?? message,
1189
+ // Local Ollama readiness is a reachability concern; configured
1190
+ // remote providers missing credentials are authentication issues.
1191
+ code: preflightError && typeof preflightError === 'object'
1192
+ ? String(preflightError.code ?? '')
1193
+ : id === 'ollama' ? 'NETWORK_FAILURE' : 'AUTHENTICATION_FAILED',
1194
+ });
1195
+ askTrace.finishSpan(preflightSpan, {
1196
+ outcome: 'unavailable',
1197
+ reasonCode: 'provider_preflight',
1198
+ payload: {
1199
+ kind: 'provider',
1200
+ attempt: {
1201
+ version: 1,
1202
+ phase: 'preflight',
1203
+ physicalAttemptIndex: 0,
1204
+ providerFingerprint: diagnostic.providerFingerprint ?? runtimeFingerprint(rawProvider.name),
1205
+ ...(diagnostic.modelFingerprint ? { modelFingerprint: diagnostic.modelFingerprint } : {}),
1206
+ ...(diagnostic.baseOriginFingerprint ? { baseOriginFingerprint: diagnostic.baseOriginFingerprint } : {}),
1207
+ readiness: 'unavailable',
1208
+ admission: 'unknown',
1209
+ retryable: diagnostic.retryable,
1210
+ safeAction: diagnostic.safeAction,
1211
+ cause: diagnostic.cause,
1212
+ provenance: 'live',
1213
+ },
1214
+ },
1215
+ });
1216
+ emit({
1217
+ kind: 'error',
1218
+ message,
1219
+ providerDiagnostic: diagnostic,
1220
+ });
1221
+ return;
1222
+ }
1223
+ askTrace.finishSpan(preflightSpan, {
1224
+ outcome: 'ok',
1225
+ reasonCode: 'completed',
1226
+ payload: {
1227
+ kind: 'provider',
1228
+ attempt: {
1229
+ version: 1,
1230
+ phase: 'preflight',
1231
+ physicalAttemptIndex: 0,
1232
+ providerFingerprint: runtimeFingerprint(rawProvider.name),
1233
+ readiness: 'ready',
1234
+ admission: 'unknown',
1235
+ provenance: 'live',
1236
+ },
1237
+ },
1238
+ });
507
1239
  }
508
1240
  try {
509
1241
  const requestStartedAt = Date.now();
510
- emit({ kind: 'thinking', text: `Using ${spec.label} through the governed DQL agent.` });
1242
+ emit({
1243
+ kind: 'thinking',
1244
+ text: providerPreflightRequired
1245
+ ? `Using ${spec.label} through the governed DQL agent.`
1246
+ : 'Executing the router-selected certified plan without an AI provider.',
1247
+ });
511
1248
  const kgPath = defaultKgPath(req.projectRoot);
512
1249
  if (!existsSync(kgPath)) {
513
1250
  emit({ kind: 'thinking', text: 'Building the local agent knowledge graph from terms, business views, blocks, apps, dashboards, dbt, and semantic metadata.' });
@@ -527,6 +1264,9 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
527
1264
  const conversationSnapshot = conversationSnapshotFromContext(req.conversationContext);
528
1265
  const rawFollowUp = followUpFromConversationContext(req, rawQuestion) ?? inferFollowUpContext(req, rawQuestion);
529
1266
  const followUp = applyTopicShiftGuard(rawFollowUp, conversationSnapshot);
1267
+ // This is a server-side decision recorded by the engine/trace. It
1268
+ // never comes from client JSON or an LLM response.
1269
+ req.conversationBinding = followUp?.binding ?? 'none';
530
1270
  // CTX-003: retrieval and planning operate on the user's current words.
531
1271
  // Prior SQL, DQL source, owners, and result metadata stay in the typed
532
1272
  // follow-up envelope rendered separately for the provider; concatenating
@@ -633,6 +1373,63 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
633
1373
  const schemaContext = req.getSchemaContext && shouldLoadSchemaContext(contextPack, Boolean(semanticLayer))
634
1374
  ? await req.getSchemaContext(question, contextPack).catch(() => [])
635
1375
  : [];
1376
+ // The router's exploratory candidate IDs are server-owned execution
1377
+ // authority. Once that tier is selected, provider prompt/schema
1378
+ // context must be the candidate closure rather than the broad
1379
+ // retrieval pack. The latter remains available to the host for
1380
+ // receipts only and cannot be used to introduce another relation.
1381
+ const forcedExploratoryTier = req.selectedCascadeTier === 'exploratory_sql';
1382
+ // Re-derive the closure from the broad immutable pack and the
1383
+ // router-selected IDs. `preparedExploratoryContextPack` is only a
1384
+ // server-side consistency witness; it cannot override or widen the
1385
+ // derivation even if a future caller constructs an AgentRunner
1386
+ // request directly.
1387
+ const derivedExploratoryContextPack = forcedExploratoryTier
1388
+ ? scopeContextPackToExploratoryCandidateClosure(contextPack, req.exploratoryCandidateIds)
1389
+ : undefined;
1390
+ const closureWitnessMatches = !req.preparedExploratoryContextPack
1391
+ || exploratoryClosureMatches(derivedExploratoryContextPack, req.preparedExploratoryContextPack);
1392
+ const exploratoryContextPack = closureWitnessMatches
1393
+ ? derivedExploratoryContextPack
1394
+ : undefined;
1395
+ if (forcedExploratoryTier && (!exploratoryContextPack || !req.exploratoryCandidateIds?.length)) {
1396
+ const text = 'The router-selected exploratory path no longer has a complete same-snapshot physical closure, so DQL did not send SQL generation or execute a query.';
1397
+ emit({
1398
+ kind: 'tool_result',
1399
+ id: 'governed_answer',
1400
+ output: {
1401
+ kind: 'no_answer',
1402
+ sourceTier: 'no_answer',
1403
+ certification: 'analyst_review_required',
1404
+ reviewStatus: 'none',
1405
+ confidence: 0,
1406
+ text,
1407
+ answer: text,
1408
+ refusalCode: 'grounding_gap',
1409
+ refusalDetails: {
1410
+ code: closureWitnessMatches ? 'EXPLORATORY_CLOSURE_UNAVAILABLE' : 'EXPLORATORY_CLOSURE_MISMATCH',
1411
+ message: text,
1412
+ },
1413
+ contextPack,
1414
+ providerUsed: provider.name,
1415
+ },
1416
+ });
1417
+ return;
1418
+ }
1419
+ const answerContextPack = forcedExploratoryTier
1420
+ ? exploratoryContextPack ?? contextPack
1421
+ : contextPack;
1422
+ const normalizeQualifiedRelation = (value) => value
1423
+ .trim()
1424
+ .split('.')
1425
+ .map((part) => part.trim().replace(/^["`\[]|["`\]]$/g, '').toLowerCase())
1426
+ .filter(Boolean)
1427
+ .join('.');
1428
+ const closureRelations = new Set((exploratoryContextPack?.allowedSqlContext.relations ?? [])
1429
+ .map((relation) => normalizeQualifiedRelation(relation.relation)));
1430
+ const answerSchemaContext = forcedExploratoryTier && closureRelations.size > 0
1431
+ ? schemaContext.filter((table) => closureRelations.has(normalizeQualifiedRelation(table.relation)))
1432
+ : schemaContext;
636
1433
  const schemaDurationMs = Date.now() - schemaStartedAt;
637
1434
  const selectedBlockHints = shouldUseSelectedBlockHint(req, question, followUp)
638
1435
  ? extractSelectedBlockHints(req)
@@ -655,8 +1452,8 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
655
1452
  question,
656
1453
  ...(conversationSnapshot ? { conversationSnapshot } : {}),
657
1454
  ...(memoryContext ? { memoryContext } : {}),
658
- schemaContext: prepareServerOwnedProviderSchemaContext(schemaContext),
659
- ...(contextPack ? { contextPack } : {}),
1455
+ schemaContext: prepareServerOwnedProviderSchemaContext(answerSchemaContext),
1456
+ ...(answerContextPack ? { contextPack: answerContextPack } : {}),
660
1457
  skills,
661
1458
  ...(followUp ? { followUp } : {}),
662
1459
  }), {
@@ -667,11 +1464,22 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
667
1464
  // The strangler seam. `answerAgentic` and `answer` are interchangeable
668
1465
  // here; which runs is a per-lane config decision that defaults to
669
1466
  // legacy, so this is a no-op until a lane is explicitly enabled.
670
- const answerLoopInput = {
1467
+ // Preserve the non-enumerable observer when the provider runner
1468
+ // projects the AgentRun request into the answer-loop input. The
1469
+ // analyst loop receives this projected object, so dropping the
1470
+ // observer here would make actual text-protocol tool calls invisible
1471
+ // even though provider, router, and SQL evidence was recorded.
1472
+ const answerLoopInput = attachAskTraceObserverV1({
671
1473
  question,
672
1474
  ...(req.resolvedAnalyticalPlan
673
1475
  ? { resolvedAnalyticalPlan: req.resolvedAnalyticalPlan }
674
1476
  : {}),
1477
+ ...(req.selectedCascadeTier
1478
+ ? { selectedCascadeTier: req.selectedCascadeTier }
1479
+ : {}),
1480
+ ...(req.exploratoryCandidateIds?.length
1481
+ ? { exploratoryCandidateIds: [...req.exploratoryCandidateIds] }
1482
+ : {}),
675
1483
  ...(req.generatedProposalTargetFingerprint
676
1484
  ? { generatedProposalTargetFingerprint: req.generatedProposalTargetFingerprint }
677
1485
  : {}),
@@ -692,7 +1500,7 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
692
1500
  followUp,
693
1501
  conversationSnapshot,
694
1502
  memoryContext,
695
- schemaContext,
1503
+ schemaContext: answerSchemaContext,
696
1504
  semanticLayer,
697
1505
  // Runtime-aware executability for metric SELECTION: with a full
698
1506
  // semantic runtime active (dbt Cloud / MetricFlow CLI) every
@@ -704,7 +1512,7 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
704
1512
  canExecuteSemanticMetric: (metricName) => semanticRuntimeActive !== 'native' || semanticLayer.canComposeMetric(metricName),
705
1513
  }
706
1514
  : {}),
707
- contextPack,
1515
+ contextPack: answerContextPack,
708
1516
  // The project's configured embedder (dql.config.json ai.embeddings).
709
1517
  // Without it, matchSemanticMetric falls back to the offline hashed
710
1518
  // provider, whose vectors can never ground a match on similarity
@@ -721,16 +1529,43 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
721
1529
  ...(req.preferredEvidenceIds?.length ? { preferredEvidenceIds: req.preferredEvidenceIds } : {}),
722
1530
  ...(req.preferredExecutionId ? { preferredExecutionId: req.preferredExecutionId } : {}),
723
1531
  executeCertifiedBlock: req.executeCertifiedBlock
724
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeCertifiedBlock(...args); }
1532
+ ? async (...args) => {
1533
+ guardSnapshot();
1534
+ return executeAtSqlBoundary(() => req.executeCertifiedBlock(...args));
1535
+ }
725
1536
  : undefined,
726
1537
  executeGeneratedSql: req.executeGeneratedSql
727
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeGeneratedSql(...args); }
1538
+ ? async (...args) => {
1539
+ guardSnapshot();
1540
+ return executeAtSqlBoundary(() => req.executeGeneratedSql(...args));
1541
+ }
1542
+ : undefined,
1543
+ prepareExploratorySqlExecution: req.prepareExploratorySqlExecution
1544
+ ? async (sql, ...args) => {
1545
+ guardSnapshot();
1546
+ // The execution host repeats this validation immediately
1547
+ // before capability minting. Keep the same exact-qualified
1548
+ // closure check here as well, before the provider runner can
1549
+ // even invoke that host boundary. A provider response cannot
1550
+ // use another same-snapshot relation merely because it was
1551
+ // present in broad retrieval diagnostics.
1552
+ if (forcedExploratoryTier && exploratoryContextPack) {
1553
+ const proposalValidation = validateSqlAgainstLocalContext(sql, exploratoryContextPack, {
1554
+ runtimeSchema: answerSchemaContext,
1555
+ });
1556
+ const outsideClosure = !proposalValidation.ok
1557
+ || proposalValidation.referencedRelations.some((relation) => !closureRelations.has(normalizeQualifiedRelation(relation)));
1558
+ if (outsideClosure) {
1559
+ throw Object.assign(new Error('The generated SQL references a relation outside the router-selected physical closure, so it was not executed.'), { code: 'UNAUTHORIZED_SQL' });
1560
+ }
1561
+ }
1562
+ return req.prepareExploratorySqlExecution(sql, ...args);
1563
+ }
728
1564
  : undefined,
729
1565
  executeAgenticGeneratedSql: req.executeAgenticGeneratedSql
730
1566
  ? async (capability, sql, artifact) => {
731
1567
  guardSnapshot();
732
- sqlExecutions += 1;
733
- return req.executeAgenticGeneratedSql(capability, sql, artifact);
1568
+ return executeAtSqlBoundary(() => req.executeAgenticGeneratedSql(capability, sql, artifact));
734
1569
  }
735
1570
  : undefined,
736
1571
  agenticExecutionScope: {
@@ -740,7 +1575,10 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
740
1575
  targetFingerprint: req.generatedProposalTargetFingerprint,
741
1576
  },
742
1577
  executeDqlArtifact: req.executeDqlArtifact
743
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeDqlArtifact(...args); }
1578
+ ? async (...args) => {
1579
+ guardSnapshot();
1580
+ return executeAtSqlBoundary(() => req.executeDqlArtifact(...args));
1581
+ }
744
1582
  : undefined,
745
1583
  expandGroundingContext: createGroundingContextExpander(req.projectRoot, req.probeNamedRelations),
746
1584
  answerLoopTools,
@@ -779,7 +1617,7 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
779
1617
  // NOTE: no captureGeneratedDraft here — a plain answer/research question must NOT
780
1618
  // auto-write a draft into the blocks space. A draft is created only when the user
781
1619
  // explicitly acts (the "Create DQL draft" action → the dql_block_draft route).
782
- };
1620
+ }, askTrace);
783
1621
  const result = await answerAgentic(answerLoopInput, {
784
1622
  policy: resolveOrchestratorPolicy({
785
1623
  config: readOrchestratorConfig(req.projectRoot),
@@ -808,14 +1646,15 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
808
1646
  maxIterations: resolveOrchestratorPolicy({
809
1647
  config: readOrchestratorConfig(req.projectRoot),
810
1648
  }).maxIterations,
811
- // Match the physical wrapper cap and reserve the final
812
- // composition dispatch: an ordinary cap of four permits
813
- // at most three text-protocol tool observations.
1649
+ // Keep the raw loop bounded by the same physical ceiling
1650
+ // as the wrapper. The run-scoped ledger still admits only
1651
+ // one ordinary generation/planning transport; a separate
1652
+ // frozen-plan repair marker is required for the third send.
814
1653
  maxProviderDispatches,
815
1654
  // Scaled by the same knob as every other agent deadline, so
816
1655
  // a local model that needs seconds per call is not planned
817
1656
  // out of existence by a budget calibrated for a hosted one.
818
- ...(turnPlanningEnabled(req.projectRoot) ? {
1657
+ ...(turnPlanningEnabled(req.projectRoot, req.orchestrationMode) ? {
819
1658
  planTurn: async (question, toolNames) => {
820
1659
  const plan = await planAnalystTurn(loopInput.provider, question, toolNames, {
821
1660
  timeoutMs: Math.round(2_500 * deadlineScale()),
@@ -829,6 +1668,11 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
829
1668
  return plan;
830
1669
  },
831
1670
  } : {}),
1671
+ // Text-protocol tool loops execute tools outside the
1672
+ // provider transport, so carry the same physical-boundary
1673
+ // observer through the loop. The marker prevents the
1674
+ // provider wrapper from duplicating native tool records.
1675
+ onToolCall: createAskTraceToolCallback(askTraceObserverForV1(loopInput)),
832
1676
  // The loop's trace, on the channel the provider already uses
833
1677
  // for progress. `thinking` turns become `onProgress`, which
834
1678
  // the run engine emits as `executor.started` over SSE — so
@@ -985,6 +1829,19 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
985
1829
  dispatchEvidence: dispatchEvidence(orchestrationBudgetExhausted ? 'orchestration_budget_exhausted'
986
1830
  : snapshotDrift ? 'project_snapshot_mismatch'
987
1831
  : 'provider_error'),
1832
+ providerDiagnostic: providerBoundaryDiagnostic({
1833
+ providerId: id,
1834
+ projectRoot: req.projectRoot,
1835
+ phase: orchestrationBudgetExhausted
1836
+ ? 'planning'
1837
+ : 'generation',
1838
+ error: err,
1839
+ code: orchestrationBudgetExhausted
1840
+ ? 'PROVIDER_DISPATCH_BUDGET'
1841
+ : snapshotDrift
1842
+ ? 'ADMISSION_DENIED'
1843
+ : budgetCode,
1844
+ }),
988
1845
  });
989
1846
  }
990
1847
  },
@@ -1279,9 +2136,13 @@ function extractSelectedBlockHints(req) {
1279
2136
  }
1280
2137
  }
1281
2138
  function inferFollowUpContext(req, question) {
1282
- // Messages-only fallback (no structured conversationContext). Regexes classify the
1283
- // kind; a non-matching question still carries the prior turn as advisory 'contextual'.
1284
- const kind = isGenericFollowUp(question) ? 'generic' : isDrilldownFollowUp(question) ? 'drilldown' : 'contextual';
2139
+ // Messages-only fallback (no structured conversationContext). A prior
2140
+ // assistant turn is not permission to carry rows, measures, or a block into
2141
+ // a complete new analytical question. Admit it only for an explicit
2142
+ // prior-result reference/operation; otherwise this returns `undefined`.
2143
+ const kind = isGenericFollowUp(question) ? 'generic' : isDrilldownFollowUp(question) ? 'drilldown' : undefined;
2144
+ if (!kind)
2145
+ return undefined;
1285
2146
  for (let i = req.messages.length - 1; i >= 0; i--) {
1286
2147
  const msg = req.messages[i];
1287
2148
  if (msg.role !== 'assistant')
@@ -1291,6 +2152,7 @@ function inferFollowUpContext(req, question) {
1291
2152
  continue;
1292
2153
  return {
1293
2154
  kind,
2155
+ binding: 'prior_result',
1294
2156
  sourceBlockName,
1295
2157
  sourceAnswer: msg.content.slice(0, 1200),
1296
2158
  filters: kind === 'drilldown' ? extractDrilldownFilters(question) : undefined,
@@ -1328,17 +2190,11 @@ function conversationSnapshotFromContext(context) {
1328
2190
  function applyTopicShiftGuard(followUp, snapshot) {
1329
2191
  if (!followUp || snapshot?.topicRelation !== 'shift')
1330
2192
  return followUp;
1331
- if (followUp.kind !== 'drilldown')
1332
- return followUp;
1333
- return {
1334
- ...followUp,
1335
- kind: 'contextual',
1336
- filters: undefined,
1337
- dimensions: undefined,
1338
- priorResultValues: undefined,
1339
- priorMeasures: undefined,
1340
- priorLimit: undefined,
1341
- };
2193
+ // A server-produced compound dependency is a direct child input, not an
2194
+ // incidental thread carry. Every other result binding is dropped on a
2195
+ // topic shift so a fresh analytical request cannot receive prior rows or
2196
+ // measures merely because the thread has history.
2197
+ return followUp.binding === 'task_dependency' ? followUp : undefined;
1342
2198
  }
1343
2199
  /**
1344
2200
  * Resolve the prior-turn context a follow-up may build on.
@@ -1365,6 +2221,7 @@ function resolveAgentFollowUpContextRaw(rawContext, question) {
1365
2221
  if (taskDependency) {
1366
2222
  return {
1367
2223
  kind: 'drilldown',
2224
+ binding: 'task_dependency',
1368
2225
  sourceTurnId: `task:${taskDependency.sourceTaskId}`,
1369
2226
  filters: [taskDependency.value],
1370
2227
  dimensions: [taskDependency.canonicalColumn],
@@ -1418,22 +2275,24 @@ function resolveAgentFollowUpContextRaw(rawContext, question) {
1418
2275
  const hasUsefulContext = Boolean(sourceBlockName || priorResultColumns?.length || focusedPriorResultValues || priorDqlArtifact);
1419
2276
  if (!hasUsefulContext)
1420
2277
  return undefined;
2278
+ // Prior-result material is execution-relevant context, not a friendly
2279
+ // transcript hint. A complete new question therefore receives no carry at
2280
+ // all. The only admission routes are a resolved typed reference, an
2281
+ // explicit prior-result operation, or a deictic/generic follow-up.
1421
2282
  const inferredKind = resolvedReferences.memberBindings?.length
1422
2283
  ? 'drilldown'
1423
2284
  : isGenericFollowUp(question)
1424
2285
  ? 'generic'
1425
2286
  : isDrilldownFollowUp(question, priorShapeTerms(priorResultColumns, focusedPriorResultValues))
1426
2287
  ? 'drilldown'
1427
- : null;
1428
- // Always-on carry: the regexes only CLASSIFY the follow-up kind — they no longer
1429
- // gate whether conversation context exists at all. A question that matches neither
1430
- // pattern still carries the prior turn as advisory 'contextual' state; the model
1431
- // (not a regex) decides whether it's relevant to the new question.
1432
- const kind = inferredKind
1433
- ?? (context.followupKind === 'generic' || context.followupKind === 'drilldown' ? context.followupKind : null)
1434
- ?? 'contextual';
2288
+ : undefined;
2289
+ if (!inferredKind)
2290
+ return undefined;
2291
+ const binding = 'prior_result';
2292
+ const kind = inferredKind;
1435
2293
  return {
1436
2294
  kind,
2295
+ binding,
1437
2296
  sourceTurnId: cleanOptionalString(activeTurn?.id) ?? cleanOptionalString(context.sourceAnswerId),
1438
2297
  // A relative comparison needs the named member from history, not the prior
1439
2298
  // block's result contract. Carrying a beverage-ranking block into "less tax
@@ -2088,6 +2947,8 @@ export const __test__ = {
2088
2947
  shouldSearchProjectFiles,
2089
2948
  renderProjectSourceSearch,
2090
2949
  researchDispatchPurposeForTool,
2950
+ readAgentConfig,
2951
+ providerBoundaryDiagnostic,
2091
2952
  };
2092
2953
  function researchDispatchPurposeForTool(toolName) {
2093
2954
  return toolName === 'execute_local_analysis' ? 'research_tool' : 'research_narration';
@@ -2126,7 +2987,7 @@ function isGenericFollowUp(question) {
2126
2987
  * Now it needs either a deictic reference to the prior result, or an explicit
2127
2988
  * drill verb that only makes sense relative to something already on screen.
2128
2989
  */
2129
- function isDrilldownFollowUp(question, priorTerms = []) {
2990
+ function isDrilldownFollowUp(question, _priorTerms = []) {
2130
2991
  const lower = question.toLowerCase();
2131
2992
  const deicticDrilldown = /\b(?:this|that|these|those|same|above|previous|prior)\s+(?:amount|value|orders?|results?|rows?|customers?|products?|cat(?:egor|agor|ogor)(?:y|ies)|segments?|regions?)\b/.test(lower)
2132
2993
  || /\b(?:they|their|them|he|she|him|his|her|hers)\b/.test(lower);
@@ -2135,15 +2996,18 @@ function isDrilldownFollowUp(question, priorTerms = []) {
2135
2996
  // a short gap between them rather than adjacently.
2136
2997
  const explicitDrillVerb = /\b(drills?|breakdowns?|slices?|segments?|splits?|why|drivers?|root cause|variance)\b/.test(lower)
2137
2998
  || /\bbreak\b.{0,16}\bdown\b/.test(lower);
2138
- // Evidence, not vocabulary: a question that reuses the PRIOR RESULT's own
2139
- // column or dimension names is talking about that result, whatever words it
2140
- // uses to do it. This is what distinguishes "who are the customers by region"
2141
- // asked after a customers table (a drilldown) from the same phrasing asked on
2142
- // a brand-new topic (not one).
2143
- const reusesPriorShape = priorTerms.some((term) => term && lower.includes(term));
2144
- if (!deicticDrilldown && !explicitDrillVerb && !reusesPriorShape)
2999
+ const hasDeicticReference = /\b(?:this|that|these|those|same|above|previous|prior|they|their|them|he|she|him|his|her|hers)\b/.test(lower);
3000
+ const resultStateReference = /\b(?:decline|drop|change|increase|decrease|variance)\b/.test(lower);
3001
+ // A shared word such as customer, revenue, or region is not a prior-result
3002
+ // reference. It appears in many unrelated analytical questions and used to
3003
+ // make a fresh request inherit the prior answer's filters and measures.
3004
+ const explicitPriorResultOperation = /\b(?:top|bottom)\s+\d+\s+of\s+(?:these|those|them|the\s+(?:prior|previous)?\s*results?)\b/.test(lower)
3005
+ || /\b(?:average|sum|total|compare)\s+(?:of|across)\s+(?:these|those|them|the\s+(?:prior|previous)?\s*results?)\b/.test(lower);
3006
+ if (!deicticDrilldown && !explicitPriorResultOperation && !(explicitDrillVerb && (hasDeicticReference || resultStateReference)))
2145
3007
  return false;
2146
- return deicticDrilldown || !/\b(what is|what are|define|definition|meaning of)\b/.test(lower);
3008
+ // Bare definitions should not inherit a prior result merely because they
3009
+ // contain a pronoun. An explicit result operation remains authoritative.
3010
+ return explicitPriorResultOperation || !/\b(what is|what are|define|definition|meaning of)\b/.test(lower);
2147
3011
  }
2148
3012
  /**
2149
3013
  * Lower-cased words from the prior result's columns and dimension keys, used to