@duckcodeailabs/dql-cli 1.14.2 → 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 (114) 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-DKbGpRQS.js → AgentLogPage-Jbyb4so-.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AiBuildDialog-DPSu0Mly.js → AiBuildDialog-Du07X1qO.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AiBuildResult-1uaGnpi1.js → AiBuildResult-xKtGthWv.js} +1 -1
  8. package/dist/assets/dql-notebook/assets/{AiSidePanel-BwMREwa7.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-CM1tPywy.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--S6WFVO4.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-CNyU5MBX.js → DbtFirstModelingPage-JKhB_Y2H.js} +3 -3
  16. package/dist/assets/dql-notebook/assets/{GitPage-IcydAai3.js → GitPage-DjaNp0o3.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{GlobalAiRail-CSKW-5eD.js → GlobalAiRail-CsV3zlc8.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{GovernedContextPage-CFen0eFT.js → GovernedContextPage-uXUHUOIJ.js} +3 -3
  19. package/dist/assets/dql-notebook/assets/{HelpDocsPage-D0D9UCIz.js → HelpDocsPage-Hbh9IeYB.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{HomePage-CmSxapR3.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-CTZJp4_r.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-DMBFB7nE.js → NewBlockModal-kMAzk91d.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{NewNotebookModal-DsC9CWlW.js → NewNotebookModal-D_cWd_6M.js} +1 -1
  28. package/dist/assets/dql-notebook/assets/{NotebookEditor-hs-kw8v9.js → NotebookEditor-Bax0lxaz.js} +25 -30
  29. package/dist/assets/dql-notebook/assets/{ReadinessPage-DJ83cMik.js → ReadinessPage-DEUIXQQY.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{SetupOnboarding-B1Pu_Bvv.js → SetupOnboarding-CyE-Ex41.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{SkillsPage-BHKDay8n.js → SkillsPage-QgvKGPxE.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{TrustBadge-BkyGgob2.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-DmNiuLQA.js → answer-to-notebook-tACdCsTV.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{arrow-left--1rsrxm8.js → arrow-left-CF6wxXU5.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{arrow-right-D5TdqY1G.js → arrow-right-C7G3M_S8.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{book-open-text-Bw7nHbzg.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-DLe6NNM4.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-C9Q97sA3.js → external-link-WciAU8Bu.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{grip-vertical-CztvkIgo.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-zHHzDn6l.js → index-D3wnucQC.js} +133 -128
  46. package/dist/assets/dql-notebook/assets/{link-2-CiKAvumL.js → link-2-C6uQx50X.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{list-tree-BtnP2nQ5.js → list-tree-DapsIuQB.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/{minimize-2-TSFGxcCP.js → minimize-2-BlVoipnT.js} +1 -1
  49. package/dist/assets/dql-notebook/assets/{panel-right-open-BfXIUWy0.js → panel-right-open-DcnKWxfx.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{play-DVbSFJHD.js → play-CNuRuaAs.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{rotate-ccw-D_cesDcX.js → rotate-ccw-B2p953Zh.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{semantic-fields-CoVStdYB.js → semantic-fields-2fWgkTJc.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{sliders-horizontal-l7xV9K5A.js → sliders-horizontal-DwRTu1w4.js} +1 -1
  54. package/dist/assets/dql-notebook/assets/{star-CSBS0H3b.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-BTrnyY4q.js → triangle-alert-BPHvH1wA.js} +1 -1
  57. package/dist/assets/dql-notebook/assets/{upload-sySLq9zb.js → upload-DIqK0KE1.js} +1 -1
  58. package/dist/assets/dql-notebook/assets/{usePersistedAgentThreadId-CzwgGdus.js → usePersistedAgentThreadId-DpQXOEDw.js} +1 -1
  59. package/dist/assets/dql-notebook/assets/{user-round-ChlgXi9j.js → user-round-DukNupB_.js} +1 -1
  60. package/dist/assets/dql-notebook/assets/{wand-sparkles-CGw0ytyT.js → wand-sparkles-CAwLX0b9.js} +1 -1
  61. package/dist/assets/dql-notebook/assets/{workflow-C_RltiK5.js → workflow-bOT-NqdO.js} +1 -1
  62. package/dist/assets/dql-notebook/assets/{wrench-D-wLfeu0.js → wrench-1m0_kcza.js} +1 -1
  63. package/dist/assets/dql-notebook/assets/{x-B28hIJIC.js → x-fp82BETM.js} +1 -1
  64. package/dist/assets/dql-notebook/index.html +2 -2
  65. package/dist/commands/agent-eval-runtime.d.ts +9 -0
  66. package/dist/commands/agent-eval-runtime.d.ts.map +1 -1
  67. package/dist/commands/agent-eval-runtime.js +7 -0
  68. package/dist/commands/agent-eval-runtime.js.map +1 -1
  69. package/dist/commands/agent-trace.d.ts +3 -0
  70. package/dist/commands/agent-trace.d.ts.map +1 -0
  71. package/dist/commands/agent-trace.js +169 -0
  72. package/dist/commands/agent-trace.js.map +1 -0
  73. package/dist/commands/agent.d.ts +28 -2
  74. package/dist/commands/agent.d.ts.map +1 -1
  75. package/dist/commands/agent.js +408 -35
  76. package/dist/commands/agent.js.map +1 -1
  77. package/dist/commands/notebook.d.ts +2 -0
  78. package/dist/commands/notebook.d.ts.map +1 -1
  79. package/dist/commands/notebook.js +4 -0
  80. package/dist/commands/notebook.js.map +1 -1
  81. package/dist/index.js +2 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/llm/providers/dql-agent-provider.d.ts +1 -1
  84. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  85. package/dist/llm/providers/dql-agent-provider.js +759 -145
  86. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  87. package/dist/llm/types.d.ts +21 -2
  88. package/dist/llm/types.d.ts.map +1 -1
  89. package/dist/local-runtime.d.ts +256 -33
  90. package/dist/local-runtime.d.ts.map +1 -1
  91. package/dist/local-runtime.js +2844 -490
  92. package/dist/local-runtime.js.map +1 -1
  93. package/dist/package.json +10 -10
  94. package/dist/providers/oauth/claude-oauth.d.ts.map +1 -1
  95. package/dist/providers/oauth/claude-oauth.js +52 -18
  96. package/dist/providers/oauth/claude-oauth.js.map +1 -1
  97. package/dist/providers/oauth/codex-oauth.d.ts.map +1 -1
  98. package/dist/providers/oauth/codex-oauth.js +72 -44
  99. package/dist/providers/oauth/codex-oauth.js.map +1 -1
  100. package/dist/providers/subscription-cli.d.ts +20 -0
  101. package/dist/providers/subscription-cli.d.ts.map +1 -1
  102. package/dist/providers/subscription-cli.js +69 -11
  103. package/dist/providers/subscription-cli.js.map +1 -1
  104. package/package.json +10 -10
  105. package/dist/assets/dql-notebook/assets/AnalyticsHome-BGfey_ve.js +0 -6
  106. package/dist/assets/dql-notebook/assets/BusinessArtifactView-BEAJ-yNW.js +0 -1
  107. package/dist/assets/dql-notebook/assets/LineageDAG-BGUcIt1B.js +0 -1
  108. package/dist/assets/dql-notebook/assets/LineageDetailView-Dx48Zfzb.js +0 -1
  109. package/dist/assets/dql-notebook/assets/LineageDrawer-zaBfSLZO.js +0 -1
  110. package/dist/assets/dql-notebook/assets/MiniLineageGraph-CdivNR1S.js +0 -1
  111. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-BYdrTaEW.js +0 -89
  112. package/dist/assets/dql-notebook/assets/dagre.esm-CW5QZdBt.js +0 -23
  113. package/dist/assets/dql-notebook/assets/index-B3shyZsg.css +0 -1
  114. /package/dist/assets/dql-notebook/assets/{dagre-BZV40eAE.css → style-BZV40eAE.css} +0 -0
@@ -1,8 +1,8 @@
1
- import { classifyProviderFailure, 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
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
4
  import { CassetteStore, evalCassetteCanonicalizationV2, resolveCassetteModeFromEnv, withCassette, } from '../../commands/agent-eval-cassette.js';
5
- import { buildManifest, normalizeDqlArtifactReference, resolveDbtManifestPath } from '@duckcodeailabs/dql-core';
5
+ import { buildManifest, normalizeDqlArtifactReference, resolveDbtManifestPath, } from '@duckcodeailabs/dql-core';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { existsSync, readFileSync, statSync } from 'node:fs';
8
8
  import { join } from 'node:path';
@@ -13,6 +13,17 @@ import { getEffectiveProviderConfig } from '../../settings/provider-settings.js'
13
13
  import { ClaudeCodeCliProvider, CodexCliProvider } from '../../providers/subscription-cli.js';
14
14
  import { ClaudeOAuthProvider, claudeOAuthConnected } from '../../providers/oauth/claude-oauth.js';
15
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
+ }
16
27
  const SPECS = {
17
28
  anthropic: {
18
29
  label: 'Anthropic Claude',
@@ -109,6 +120,103 @@ function providerBoundaryDiagnostic(input) {
109
120
  baseOriginFingerprint: fingerprint(origin),
110
121
  });
111
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
+ }
112
220
  function createCertifiedFitConfirmation(provider, signal) {
113
221
  return async ({ question, questionPlan, block, fit }) => {
114
222
  const response = await provider.generate([
@@ -404,10 +512,15 @@ function readOrchestratorConfig(projectRoot) {
404
512
  * measured on ollama, where all three agentic dispatches planned successfully
405
513
  * and then died at "soft target elapsed before this provider dispatch could
406
514
  * start". Its payoff is a legible trace, and `onStep` is not wired to SSE yet,
407
- * so today it buys nothing a user can see. Opt in with
408
- * `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.
409
520
  */
410
- function turnPlanningEnabled(projectRoot) {
521
+ function turnPlanningEnabled(projectRoot, orchestrationMode) {
522
+ if (orchestrationMode !== 'research')
523
+ return false;
411
524
  const orchestrator = readAgentConfig(projectRoot)?.orchestrator;
412
525
  if (!orchestrator || typeof orchestrator !== 'object')
413
526
  return false;
@@ -495,8 +608,24 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
495
608
  async run(req, emit, signal) {
496
609
  const spec = SPECS[id];
497
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;
498
619
  const isResearch = req.orchestrationMode === 'research';
499
- 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;
500
629
  const researchRowsOptIn = isResearch && req.researchResultRowsOptIn === true;
501
630
  const sharedDispatchEvidence = req.providerDispatchEvidenceSink;
502
631
  let providerRoundTrips = 0;
@@ -505,6 +634,11 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
505
634
  let pendingColumnCount = 0;
506
635
  let pendingCumulativeResultRowCount = 0;
507
636
  let pendingResearchPurpose = 'research_narration';
637
+ let physicalToolAttemptIndex = 0;
638
+ let physicalProviderAttemptIndex = 0;
639
+ let lastFailedProviderSpanId;
640
+ let physicalDispatchSequence = 0;
641
+ let lastAdmittedPhysicalDispatch;
508
642
  const providerEgressReceipts = [];
509
643
  const dispatchEvidence = (fallbackReason) => {
510
644
  const shared = sharedDispatchEvidence?.snapshot(fallbackReason);
@@ -519,145 +653,598 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
519
653
  fallbackReason,
520
654
  };
521
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
+ };
522
676
  signal.addEventListener('abort', () => {
523
677
  const reason = signal.reason;
524
678
  if (reason && typeof reason === 'object') {
525
679
  Object.assign(reason, { providerDispatchEvidence: dispatchEvidence('cancelled') });
526
680
  }
527
681
  }, { once: true });
528
- const withPhysicalDispatchObserver = (options) => ({
529
- ...(options ?? {}),
530
- // A tool-calling loop capped at two physical sends can make exactly one
531
- // tool call before it must answer, which is not enough to look something
532
- // up and then use it. The run-scoped ledger is the real guardrail.
533
- maxProviderDispatches,
534
- ...(sharedDispatchEvidence?.mayStartToolCall
535
- ? { mayStartToolCall: () => sharedDispatchEvidence.mayStartToolCall() }
536
- : {}),
537
- onProviderDispatch: (event) => {
538
- const purpose = isResearch ? pendingResearchPurpose : 'answer_generation';
539
- if (sharedDispatchEvidence) {
540
- const envelope = sharedDispatchEvidence.observe(event, {
541
- purpose,
542
- dispatchPhase: 'generation',
543
- optIn: pendingResultRowCount > 0 && researchRowsOptIn,
544
- serializedResultShape: {
545
- resultRowCount: pendingResultRowCount,
546
- columnCount: pendingColumnCount,
547
- },
548
- ...(pendingCumulativeResultRowCount > 0
549
- ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
550
- : {}),
551
- });
552
- pendingResultRowCount = 0;
553
- pendingColumnCount = 0;
554
- pendingCumulativeResultRowCount = 0;
555
- pendingResearchPurpose = 'research_narration';
556
- 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;
557
689
  }
558
- // Fallback path only (CLI/MCP-direct runs carry no shared ledger).
559
- // Kept in step with `maxProviderDispatches` above so the same question
560
- // does not get a different budget depending on which surface asked it.
561
- if (!isResearch && providerRoundTrips >= 4) {
562
- throw Object.assign(new Error('Provider dispatch budget exhausted after four ordinary generation attempts.'), {
563
- 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 },
564
813
  });
814
+ return;
565
815
  }
566
- const envelope = prepareProviderWireEnvelopeForDispatch(rawProvider.name, event.envelope);
567
- assertProviderPayloadAllowed(envelope, {
568
- allowResultRows: false,
569
- maxResultRows: 0,
570
- 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 },
571
828
  });
572
- providerRoundTrips += 1;
573
- providerEgressReceipts.push(createProviderDispatchEgressReceipt({
574
- purpose,
575
- dispatchPhase: 'generation',
576
- provider: rawProvider.name,
577
- permittedCategories: pendingResultRowCount > 0
578
- ? ['instructions', 'question', 'schema_metadata', 'governed_context', 'result_rows']
579
- : ['instructions', 'question', 'schema_metadata', 'governed_context'],
580
- optIn: pendingResultRowCount > 0 && researchRowsOptIn,
581
- envelope,
582
- serializedResultShape: {
583
- resultRowCount: pendingResultRowCount,
584
- columnCount: pendingColumnCount,
585
- },
586
- ...(pendingCumulativeResultRowCount > 0
587
- ? { cumulativeResultRowCount: pendingCumulativeResultRowCount }
588
- : {}),
589
- }));
590
- pendingResultRowCount = 0;
591
- pendingColumnCount = 0;
592
- pendingCumulativeResultRowCount = 0;
593
- pendingResearchPurpose = 'research_narration';
594
- return envelope;
595
- },
596
- });
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
+ };
597
1071
  // One retry only, on the SAME configured provider and only for failures
598
1072
  // that are normally transient. The physical-dispatch observer remains in
599
1073
  // the path for both attempts, so a retry cannot exceed the run-wide
600
1074
  // dispatch budget or silently fail over to another provider.
601
1075
  let transientRetryUsed = false;
602
- const mayRetrySameProvider = (error) => {
603
- if (signal.aborted || transientRetryUsed)
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)
604
1087
  return false;
605
- const code = error && typeof error === 'object' ? String(error.code ?? '') : '';
606
- const message = error instanceof Error ? error.message : String(error ?? '');
607
- const diagnostic = `${code} ${message}`;
608
- return !/PROVIDER_DISPATCH_BUDGET|RUN_SOFT_TARGET|RUN_DEADLINE|PROJECT_SNAPSHOT|abort|cancel/i.test(diagnostic)
609
- && /(?:429|rate limit|too many requests|502|503|504|gateway|econn|network|fetch failed|connection refused|timeout|timed out)/i.test(diagnostic);
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');
610
1093
  };
611
- const retrySameProviderOnce = async (operation) => {
1094
+ const retrySameProviderOnce = async (sourceOptions, operation) => {
1095
+ const identity = providerDispatchIdentity(sourceOptions);
1096
+ const dispatchSequenceBefore = physicalDispatchSequence;
612
1097
  try {
613
- return await operation();
1098
+ return await operation(sourceOptions);
614
1099
  }
615
1100
  catch (error) {
616
- if (!mayRetrySameProvider(error))
1101
+ const parent = lastAdmittedPhysicalDispatch?.sequence && lastAdmittedPhysicalDispatch.sequence > dispatchSequenceBefore
1102
+ ? lastAdmittedPhysicalDispatch
1103
+ : undefined;
1104
+ if (!mayRetrySameProvider(error, identity, parent))
617
1105
  throw error;
618
1106
  transientRetryUsed = true;
619
1107
  emit({ kind: 'thinking', text: 'The configured AI provider had a transient error; retrying it once within this run budget.' });
620
- return operation();
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,
1121
+ },
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;
621
1140
  }
622
1141
  };
623
1142
  const provider = {
624
1143
  name: rawProvider.name,
625
1144
  available: () => rawProvider.available(),
626
- generate: (...args) => {
627
- return retrySameProviderOnce(() => rawProvider.generate(args[0], withPhysicalDispatchObserver(args[1])));
628
- },
1145
+ generate: (...args) => retrySameProviderOnce(args[1], (sourceOptions) => invokePhysicalProvider(sourceOptions, (options) => rawProvider.generate(args[0], options))),
629
1146
  ...(rawProvider.generateWithTools ? {
630
- generateWithTools: (...args) => {
631
- return retrySameProviderOnce(() => rawProvider.generateWithTools(args[0], args[1], withPhysicalDispatchObserver(args[2])));
632
- },
1147
+ generateWithTools: (...args) => retrySameProviderOnce(args[2], (sourceOptions) => invokePhysicalProvider(sourceOptions, (options) => rawProvider.generateWithTools(args[0], args[1], options))),
633
1148
  } : {}),
634
1149
  ...(rawProvider.generateStream ? {
635
- generateStream: (...args) => {
636
- return rawProvider.generateStream(args[0], withPhysicalDispatchObserver(args[1]), args[2]);
637
- },
1150
+ generateStream: (...args) => invokePhysicalProvider(args[1], (options) => rawProvider.generateStream(args[0], options, args[2])),
638
1151
  } : {}),
639
1152
  };
640
- const available = await provider.available().catch(() => false);
641
- if (!available) {
642
- const message = `${spec.label} is not configured or reachable. ${spec.setup}`;
643
- emit({
644
- kind: 'error',
645
- message,
646
- providerDiagnostic: providerBoundaryDiagnostic({
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({
647
1185
  providerId: id,
648
1186
  projectRoot: req.projectRoot,
649
1187
  phase: 'preflight',
650
- error: message,
1188
+ error: preflightError ?? message,
651
1189
  // Local Ollama readiness is a reachability concern; configured
652
1190
  // remote providers missing credentials are authentication issues.
653
- code: id === 'ollama' ? 'NETWORK_FAILURE' : 'AUTHENTICATION_FAILED',
654
- }),
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
+ },
655
1238
  });
656
- return;
657
1239
  }
658
1240
  try {
659
1241
  const requestStartedAt = Date.now();
660
- 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
+ });
661
1248
  const kgPath = defaultKgPath(req.projectRoot);
662
1249
  if (!existsSync(kgPath)) {
663
1250
  emit({ kind: 'thinking', text: 'Building the local agent knowledge graph from terms, business views, blocks, apps, dashboards, dbt, and semantic metadata.' });
@@ -677,6 +1264,9 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
677
1264
  const conversationSnapshot = conversationSnapshotFromContext(req.conversationContext);
678
1265
  const rawFollowUp = followUpFromConversationContext(req, rawQuestion) ?? inferFollowUpContext(req, rawQuestion);
679
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';
680
1270
  // CTX-003: retrieval and planning operate on the user's current words.
681
1271
  // Prior SQL, DQL source, owners, and result metadata stay in the typed
682
1272
  // follow-up envelope rendered separately for the provider; concatenating
@@ -874,7 +1464,12 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
874
1464
  // The strangler seam. `answerAgentic` and `answer` are interchangeable
875
1465
  // here; which runs is a per-lane config decision that defaults to
876
1466
  // legacy, so this is a no-op until a lane is explicitly enabled.
877
- 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({
878
1473
  question,
879
1474
  ...(req.resolvedAnalyticalPlan
880
1475
  ? { resolvedAnalyticalPlan: req.resolvedAnalyticalPlan }
@@ -934,10 +1529,16 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
934
1529
  ...(req.preferredEvidenceIds?.length ? { preferredEvidenceIds: req.preferredEvidenceIds } : {}),
935
1530
  ...(req.preferredExecutionId ? { preferredExecutionId: req.preferredExecutionId } : {}),
936
1531
  executeCertifiedBlock: req.executeCertifiedBlock
937
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeCertifiedBlock(...args); }
1532
+ ? async (...args) => {
1533
+ guardSnapshot();
1534
+ return executeAtSqlBoundary(() => req.executeCertifiedBlock(...args));
1535
+ }
938
1536
  : undefined,
939
1537
  executeGeneratedSql: req.executeGeneratedSql
940
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeGeneratedSql(...args); }
1538
+ ? async (...args) => {
1539
+ guardSnapshot();
1540
+ return executeAtSqlBoundary(() => req.executeGeneratedSql(...args));
1541
+ }
941
1542
  : undefined,
942
1543
  prepareExploratorySqlExecution: req.prepareExploratorySqlExecution
943
1544
  ? async (sql, ...args) => {
@@ -964,8 +1565,7 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
964
1565
  executeAgenticGeneratedSql: req.executeAgenticGeneratedSql
965
1566
  ? async (capability, sql, artifact) => {
966
1567
  guardSnapshot();
967
- sqlExecutions += 1;
968
- return req.executeAgenticGeneratedSql(capability, sql, artifact);
1568
+ return executeAtSqlBoundary(() => req.executeAgenticGeneratedSql(capability, sql, artifact));
969
1569
  }
970
1570
  : undefined,
971
1571
  agenticExecutionScope: {
@@ -975,7 +1575,10 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
975
1575
  targetFingerprint: req.generatedProposalTargetFingerprint,
976
1576
  },
977
1577
  executeDqlArtifact: req.executeDqlArtifact
978
- ? async (...args) => { guardSnapshot(); sqlExecutions += 1; return req.executeDqlArtifact(...args); }
1578
+ ? async (...args) => {
1579
+ guardSnapshot();
1580
+ return executeAtSqlBoundary(() => req.executeDqlArtifact(...args));
1581
+ }
979
1582
  : undefined,
980
1583
  expandGroundingContext: createGroundingContextExpander(req.projectRoot, req.probeNamedRelations),
981
1584
  answerLoopTools,
@@ -1014,7 +1617,7 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
1014
1617
  // NOTE: no captureGeneratedDraft here — a plain answer/research question must NOT
1015
1618
  // auto-write a draft into the blocks space. A draft is created only when the user
1016
1619
  // explicitly acts (the "Create DQL draft" action → the dql_block_draft route).
1017
- };
1620
+ }, askTrace);
1018
1621
  const result = await answerAgentic(answerLoopInput, {
1019
1622
  policy: resolveOrchestratorPolicy({
1020
1623
  config: readOrchestratorConfig(req.projectRoot),
@@ -1043,14 +1646,15 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
1043
1646
  maxIterations: resolveOrchestratorPolicy({
1044
1647
  config: readOrchestratorConfig(req.projectRoot),
1045
1648
  }).maxIterations,
1046
- // Match the physical wrapper cap and reserve the final
1047
- // composition dispatch: an ordinary cap of four permits
1048
- // 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.
1049
1653
  maxProviderDispatches,
1050
1654
  // Scaled by the same knob as every other agent deadline, so
1051
1655
  // a local model that needs seconds per call is not planned
1052
1656
  // out of existence by a budget calibrated for a hosted one.
1053
- ...(turnPlanningEnabled(req.projectRoot) ? {
1657
+ ...(turnPlanningEnabled(req.projectRoot, req.orchestrationMode) ? {
1054
1658
  planTurn: async (question, toolNames) => {
1055
1659
  const plan = await planAnalystTurn(loopInput.provider, question, toolNames, {
1056
1660
  timeoutMs: Math.round(2_500 * deadlineScale()),
@@ -1064,6 +1668,11 @@ export function createDqlAgentProviderRunner(id, providerOverride) {
1064
1668
  return plan;
1065
1669
  },
1066
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)),
1067
1676
  // The loop's trace, on the channel the provider already uses
1068
1677
  // for progress. `thinking` turns become `onProgress`, which
1069
1678
  // the run engine emits as `executor.started` over SSE — so
@@ -1527,9 +2136,13 @@ function extractSelectedBlockHints(req) {
1527
2136
  }
1528
2137
  }
1529
2138
  function inferFollowUpContext(req, question) {
1530
- // Messages-only fallback (no structured conversationContext). Regexes classify the
1531
- // kind; a non-matching question still carries the prior turn as advisory 'contextual'.
1532
- 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;
1533
2146
  for (let i = req.messages.length - 1; i >= 0; i--) {
1534
2147
  const msg = req.messages[i];
1535
2148
  if (msg.role !== 'assistant')
@@ -1539,6 +2152,7 @@ function inferFollowUpContext(req, question) {
1539
2152
  continue;
1540
2153
  return {
1541
2154
  kind,
2155
+ binding: 'prior_result',
1542
2156
  sourceBlockName,
1543
2157
  sourceAnswer: msg.content.slice(0, 1200),
1544
2158
  filters: kind === 'drilldown' ? extractDrilldownFilters(question) : undefined,
@@ -1576,17 +2190,11 @@ function conversationSnapshotFromContext(context) {
1576
2190
  function applyTopicShiftGuard(followUp, snapshot) {
1577
2191
  if (!followUp || snapshot?.topicRelation !== 'shift')
1578
2192
  return followUp;
1579
- if (followUp.kind !== 'drilldown')
1580
- return followUp;
1581
- return {
1582
- ...followUp,
1583
- kind: 'contextual',
1584
- filters: undefined,
1585
- dimensions: undefined,
1586
- priorResultValues: undefined,
1587
- priorMeasures: undefined,
1588
- priorLimit: undefined,
1589
- };
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;
1590
2198
  }
1591
2199
  /**
1592
2200
  * Resolve the prior-turn context a follow-up may build on.
@@ -1613,6 +2221,7 @@ function resolveAgentFollowUpContextRaw(rawContext, question) {
1613
2221
  if (taskDependency) {
1614
2222
  return {
1615
2223
  kind: 'drilldown',
2224
+ binding: 'task_dependency',
1616
2225
  sourceTurnId: `task:${taskDependency.sourceTaskId}`,
1617
2226
  filters: [taskDependency.value],
1618
2227
  dimensions: [taskDependency.canonicalColumn],
@@ -1666,22 +2275,24 @@ function resolveAgentFollowUpContextRaw(rawContext, question) {
1666
2275
  const hasUsefulContext = Boolean(sourceBlockName || priorResultColumns?.length || focusedPriorResultValues || priorDqlArtifact);
1667
2276
  if (!hasUsefulContext)
1668
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.
1669
2282
  const inferredKind = resolvedReferences.memberBindings?.length
1670
2283
  ? 'drilldown'
1671
2284
  : isGenericFollowUp(question)
1672
2285
  ? 'generic'
1673
2286
  : isDrilldownFollowUp(question, priorShapeTerms(priorResultColumns, focusedPriorResultValues))
1674
2287
  ? 'drilldown'
1675
- : null;
1676
- // Always-on carry: the regexes only CLASSIFY the follow-up kind — they no longer
1677
- // gate whether conversation context exists at all. A question that matches neither
1678
- // pattern still carries the prior turn as advisory 'contextual' state; the model
1679
- // (not a regex) decides whether it's relevant to the new question.
1680
- const kind = inferredKind
1681
- ?? (context.followupKind === 'generic' || context.followupKind === 'drilldown' ? context.followupKind : null)
1682
- ?? 'contextual';
2288
+ : undefined;
2289
+ if (!inferredKind)
2290
+ return undefined;
2291
+ const binding = 'prior_result';
2292
+ const kind = inferredKind;
1683
2293
  return {
1684
2294
  kind,
2295
+ binding,
1685
2296
  sourceTurnId: cleanOptionalString(activeTurn?.id) ?? cleanOptionalString(context.sourceAnswerId),
1686
2297
  // A relative comparison needs the named member from history, not the prior
1687
2298
  // block's result contract. Carrying a beverage-ranking block into "less tax
@@ -2376,7 +2987,7 @@ function isGenericFollowUp(question) {
2376
2987
  * Now it needs either a deictic reference to the prior result, or an explicit
2377
2988
  * drill verb that only makes sense relative to something already on screen.
2378
2989
  */
2379
- function isDrilldownFollowUp(question, priorTerms = []) {
2990
+ function isDrilldownFollowUp(question, _priorTerms = []) {
2380
2991
  const lower = question.toLowerCase();
2381
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)
2382
2993
  || /\b(?:they|their|them|he|she|him|his|her|hers)\b/.test(lower);
@@ -2385,15 +2996,18 @@ function isDrilldownFollowUp(question, priorTerms = []) {
2385
2996
  // a short gap between them rather than adjacently.
2386
2997
  const explicitDrillVerb = /\b(drills?|breakdowns?|slices?|segments?|splits?|why|drivers?|root cause|variance)\b/.test(lower)
2387
2998
  || /\bbreak\b.{0,16}\bdown\b/.test(lower);
2388
- // Evidence, not vocabulary: a question that reuses the PRIOR RESULT's own
2389
- // column or dimension names is talking about that result, whatever words it
2390
- // uses to do it. This is what distinguishes "who are the customers by region"
2391
- // asked after a customers table (a drilldown) from the same phrasing asked on
2392
- // a brand-new topic (not one).
2393
- const reusesPriorShape = priorTerms.some((term) => term && lower.includes(term));
2394
- 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)))
2395
3007
  return false;
2396
- 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);
2397
3011
  }
2398
3012
  /**
2399
3013
  * Lower-cased words from the prior result's columns and dimension keys, used to