@duckcodeailabs/dql-agent 1.6.32 → 1.6.34

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 (144) hide show
  1. package/dist/agent-run-engine.d.ts +26 -8
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +46 -7
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +15 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts.map +1 -1
  9. package/dist/agent-run-planner.js +14 -3
  10. package/dist/agent-run-planner.js.map +1 -1
  11. package/dist/agentic/answer-contract.d.ts +37 -0
  12. package/dist/agentic/answer-contract.d.ts.map +1 -0
  13. package/dist/agentic/answer-contract.js +47 -0
  14. package/dist/agentic/answer-contract.js.map +1 -0
  15. package/dist/agentic/tool-loop.d.ts +48 -0
  16. package/dist/agentic/tool-loop.d.ts.map +1 -0
  17. package/dist/agentic/tool-loop.js +181 -0
  18. package/dist/agentic/tool-loop.js.map +1 -0
  19. package/dist/agentic/toolset.d.ts +40 -0
  20. package/dist/agentic/toolset.d.ts.map +1 -0
  21. package/dist/agentic/toolset.js +265 -0
  22. package/dist/agentic/toolset.js.map +1 -0
  23. package/dist/answer-loop.d.ts +25 -2
  24. package/dist/answer-loop.d.ts.map +1 -1
  25. package/dist/answer-loop.js +368 -72
  26. package/dist/answer-loop.js.map +1 -1
  27. package/dist/cascade/budgets.d.ts +20 -2
  28. package/dist/cascade/budgets.d.ts.map +1 -1
  29. package/dist/cascade/budgets.js +76 -43
  30. package/dist/cascade/budgets.js.map +1 -1
  31. package/dist/cascade/route-policy.d.ts +1 -1
  32. package/dist/cascade/route-policy.d.ts.map +1 -1
  33. package/dist/cascade/route-policy.js +1 -0
  34. package/dist/cascade/route-policy.js.map +1 -1
  35. package/dist/embeddings/provider.d.ts +67 -4
  36. package/dist/embeddings/provider.d.ts.map +1 -1
  37. package/dist/embeddings/provider.js +167 -6
  38. package/dist/embeddings/provider.js.map +1 -1
  39. package/dist/hints/correction-eval.d.ts +30 -0
  40. package/dist/hints/correction-eval.d.ts.map +1 -0
  41. package/dist/hints/correction-eval.js +63 -0
  42. package/dist/hints/correction-eval.js.map +1 -0
  43. package/dist/hints/staleness.d.ts +30 -0
  44. package/dist/hints/staleness.d.ts.map +1 -0
  45. package/dist/hints/staleness.js +38 -0
  46. package/dist/hints/staleness.js.map +1 -0
  47. package/dist/hints/store.d.ts.map +1 -1
  48. package/dist/hints/store.js +3 -32
  49. package/dist/hints/store.js.map +1 -1
  50. package/dist/index.d.ts +39 -6
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +143 -7
  53. package/dist/index.js.map +1 -1
  54. package/dist/intent-controller.d.ts.map +1 -1
  55. package/dist/intent-controller.js +15 -3
  56. package/dist/intent-controller.js.map +1 -1
  57. package/dist/kg/sqlite-fts.d.ts +13 -0
  58. package/dist/kg/sqlite-fts.d.ts.map +1 -1
  59. package/dist/kg/sqlite-fts.js +75 -47
  60. package/dist/kg/sqlite-fts.js.map +1 -1
  61. package/dist/memory/fts-query.d.ts +37 -4
  62. package/dist/memory/fts-query.d.ts.map +1 -1
  63. package/dist/memory/fts-query.js +53 -12
  64. package/dist/memory/fts-query.js.map +1 -1
  65. package/dist/metadata/analysis-planner.js +45 -10
  66. package/dist/metadata/analysis-planner.js.map +1 -1
  67. package/dist/metadata/block-adapt.d.ts +37 -0
  68. package/dist/metadata/block-adapt.d.ts.map +1 -0
  69. package/dist/metadata/block-adapt.js +89 -0
  70. package/dist/metadata/block-adapt.js.map +1 -0
  71. package/dist/metadata/catalog.d.ts +39 -0
  72. package/dist/metadata/catalog.d.ts.map +1 -1
  73. package/dist/metadata/catalog.js +361 -41
  74. package/dist/metadata/catalog.js.map +1 -1
  75. package/dist/metadata/example-match.d.ts +30 -0
  76. package/dist/metadata/example-match.d.ts.map +1 -0
  77. package/dist/metadata/example-match.js +69 -0
  78. package/dist/metadata/example-match.js.map +1 -0
  79. package/dist/metadata/grain-ledger.d.ts +51 -0
  80. package/dist/metadata/grain-ledger.d.ts.map +1 -0
  81. package/dist/metadata/grain-ledger.js +174 -0
  82. package/dist/metadata/grain-ledger.js.map +1 -0
  83. package/dist/metadata/metric-match.d.ts +21 -1
  84. package/dist/metadata/metric-match.d.ts.map +1 -1
  85. package/dist/metadata/metric-match.js +157 -21
  86. package/dist/metadata/metric-match.js.map +1 -1
  87. package/dist/metadata/sql-grounding.d.ts +4 -2
  88. package/dist/metadata/sql-grounding.d.ts.map +1 -1
  89. package/dist/metadata/sql-grounding.js +4 -2
  90. package/dist/metadata/sql-grounding.js.map +1 -1
  91. package/dist/metadata/sql-retrieval.d.ts +3 -2
  92. package/dist/metadata/sql-retrieval.d.ts.map +1 -1
  93. package/dist/metadata/sql-retrieval.js +23 -5
  94. package/dist/metadata/sql-retrieval.js.map +1 -1
  95. package/dist/propose/build-from-prompt.d.ts.map +1 -1
  96. package/dist/propose/build-from-prompt.js +10 -4
  97. package/dist/propose/build-from-prompt.js.map +1 -1
  98. package/dist/propose/failure-analysis.d.ts +48 -0
  99. package/dist/propose/failure-analysis.d.ts.map +1 -0
  100. package/dist/propose/failure-analysis.js +71 -0
  101. package/dist/propose/failure-analysis.js.map +1 -0
  102. package/dist/propose/join-mining.d.ts +18 -0
  103. package/dist/propose/join-mining.d.ts.map +1 -0
  104. package/dist/propose/join-mining.js +74 -0
  105. package/dist/propose/join-mining.js.map +1 -0
  106. package/dist/providers/claude.d.ts.map +1 -1
  107. package/dist/providers/claude.js +36 -1
  108. package/dist/providers/claude.js.map +1 -1
  109. package/dist/providers/index.d.ts +2 -2
  110. package/dist/providers/index.d.ts.map +1 -1
  111. package/dist/providers/index.js +1 -1
  112. package/dist/providers/index.js.map +1 -1
  113. package/dist/providers/openai.d.ts.map +1 -1
  114. package/dist/providers/openai.js +34 -1
  115. package/dist/providers/openai.js.map +1 -1
  116. package/dist/providers/reasoning-effort.d.ts +27 -0
  117. package/dist/providers/reasoning-effort.d.ts.map +1 -1
  118. package/dist/providers/reasoning-effort.js +32 -0
  119. package/dist/providers/reasoning-effort.js.map +1 -1
  120. package/dist/providers/types.d.ts +2 -1
  121. package/dist/providers/types.d.ts.map +1 -1
  122. package/dist/research-loop.d.ts.map +1 -1
  123. package/dist/research-loop.js +9 -2
  124. package/dist/research-loop.js.map +1 -1
  125. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  126. package/dist/semantic-bridge/compose.js +147 -20
  127. package/dist/semantic-bridge/compose.js.map +1 -1
  128. package/dist/skills/bootstrap.d.ts +57 -0
  129. package/dist/skills/bootstrap.d.ts.map +1 -0
  130. package/dist/skills/bootstrap.js +260 -0
  131. package/dist/skills/bootstrap.js.map +1 -0
  132. package/dist/skills/defaults.d.ts +2 -2
  133. package/dist/skills/defaults.js +2 -2
  134. package/dist/skills/domain-skills.d.ts +24 -0
  135. package/dist/skills/domain-skills.d.ts.map +1 -0
  136. package/dist/skills/domain-skills.js +76 -0
  137. package/dist/skills/domain-skills.js.map +1 -0
  138. package/dist/skills/loader.d.ts +46 -8
  139. package/dist/skills/loader.d.ts.map +1 -1
  140. package/dist/skills/loader.js +186 -41
  141. package/dist/skills/loader.js.map +1 -1
  142. package/dist/tools/registry.js +12 -12
  143. package/dist/tools/registry.js.map +1 -1
  144. package/package.json +4 -4
@@ -14,22 +14,28 @@
14
14
  * step. Tests can mock the provider with a canned response and exercise the
15
15
  * full pipeline.
16
16
  */
17
- import { buildSkillBlockHints, buildSkillsPrompt, selectRelevantSkills } from './skills/loader.js';
17
+ import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
18
+ import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
18
19
  import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
19
20
  import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
20
21
  import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit } from './metadata/block-fit.js';
21
- import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricSql } from './metadata/metric-match.js';
22
+ import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricDefinition, resolveGovernedMetricSql } from './metadata/metric-match.js';
22
23
  import { decideAgentAction } from './intent-controller.js';
23
24
  import { createContextLedger } from './grounding/context-ledger.js';
24
25
  import { validateAnswerResultShape } from './answer-shape.js';
26
+ import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
27
+ import { planCertifiedAdaptation } from './metadata/block-adapt.js';
25
28
  import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
26
29
  import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
30
+ import { runAgenticToolLoop } from './agentic/tool-loop.js';
31
+ import { buildSemanticStageTools } from './agentic/toolset.js';
32
+ import { deriveAgenticTrust } from './agentic/answer-contract.js';
27
33
  import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
28
34
  import { normalizeValueIndexText } from './grounding/value-index.js';
29
35
  import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
30
36
  import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
31
37
  import { stampTrustLabel } from './trust/stamp.js';
32
- import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
38
+ import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, deepAlternativeCountForQuestion, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
33
39
  /**
34
40
  * Physical column names whose sampled runtime values include `value`. Used by the
35
41
  * semantic bridge to bind a filter literal to the dimension that actually carries
@@ -157,10 +163,12 @@ export async function answer(input) {
157
163
  },
158
164
  isFollowUp: Boolean(input.followUp),
159
165
  });
166
+ const trustLabelInfo = stampTrustLabel(result);
160
167
  return {
161
168
  ...publicResult,
162
169
  intentDecision,
163
- trustLabelInfo: stampTrustLabel(result),
170
+ trustLabelInfo,
171
+ provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
164
172
  cascade: publicResult.cascade ?? createCascadeAnswerResult({
165
173
  routeTier: chosenRoute.tier,
166
174
  label: chosenRoute.label,
@@ -178,7 +186,13 @@ export async function answer(input) {
178
186
  // Stamp the SELECTED skills that shaped the answer (transparency). Computed
179
187
  // here so every return site inside runAnswerLoop stays untouched.
180
188
  appliedSkills: result.appliedSkills ??
181
- selectRelevantSkills(input.skills ?? [], input.question, { userId: input.userId ?? null }).map((s) => ({
189
+ selectRelevantSkills(input.skills ?? [], input.question, {
190
+ userId: input.userId ?? null,
191
+ domains: Array.from(new Set([
192
+ ...(input.domain ? [input.domain] : []),
193
+ ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
194
+ ])),
195
+ }).map((s) => ({
182
196
  id: s.id,
183
197
  description: s.description,
184
198
  })),
@@ -240,11 +254,22 @@ async function runAnswerLoop(input) {
240
254
  // Select the RELEVANT skills (not all) for this question; keep pinned project
241
255
  // skills (SQL conventions). Block hints still come from the full set so a
242
256
  // preferred-block mapping is never lost.
243
- const selectedSkills = selectRelevantSkills(skills, question, { userId: userId ?? null });
257
+ const inferredDomains = Array.from(new Set([
258
+ ...(domain ? [domain] : []),
259
+ ...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
260
+ ]));
261
+ const selectedSkills = selectRelevantSkills(skills, question, {
262
+ userId: userId ?? null,
263
+ domains: inferredDomains,
264
+ });
244
265
  const effectiveBlockHints = Array.from(new Set([
245
266
  ...blockHints,
246
- ...buildSkillBlockHints(skills, userId ?? null),
267
+ // Only selected skills may influence block ranking. Previously a preferred
268
+ // block from any active but unrelated domain skill could jump to the front.
269
+ ...buildSkillBlockHints(selectedSkills, userId ?? null),
247
270
  ]));
271
+ const effectiveMetricHints = buildSkillMetricHints(selectedSkills, userId ?? null);
272
+ const semanticQuestion = expandQuestionWithSkillVocabulary(question, selectedSkills, userId ?? null);
248
273
  const followUpSourceBlock = input.followUp?.sourceBlockName
249
274
  ? kg.getNode(`block:${input.followUp.sourceBlockName}`)
250
275
  : null;
@@ -272,6 +297,19 @@ async function runAnswerLoop(input) {
272
297
  schemaContext,
273
298
  });
274
299
  let intent = catalogRoute ? agentIntentFromCatalogRoute(catalogRoute) : fallbackIntent;
300
+ // Resolve a governed metric before accepting a catalog-proposed certified
301
+ // block. Catalog token overlap can call a generic word such as "total" an
302
+ // exact block match (for example total tax → total revenue); the metric match
303
+ // is the precise measure signal that prevents that wrong Tier-1 shortcut.
304
+ const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
305
+ for (const metric of effectiveMetricHints) {
306
+ const node = kg.getNode(`metric:${metric}`);
307
+ if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
308
+ semanticMetricNodes.push(node);
309
+ }
310
+ let semanticMetricMatch = await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
311
+ measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
312
+ }).catch(() => null);
275
313
  // Stage 1: certified artifact match. Blocks can be executed; dashboards,
276
314
  // Apps, and notebooks are returned as governed citations/navigation targets.
277
315
  const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
@@ -284,19 +322,29 @@ async function runAnswerLoop(input) {
284
322
  kg,
285
323
  })
286
324
  : null;
287
- const artifactHit = drilldownCertifiedHit ?? (shouldUseCertifiedRoute(catalogRoute, intent)
325
+ const shouldTryCertifiedRoute = shouldUseCertifiedRoute(catalogRoute, intent);
326
+ const catalogCertifiedHit = shouldTryCertifiedRoute
288
327
  ? certifiedHitFromContextPack(input.contextPack, kg)
289
- ?? pickCertifiedArtifact({
290
- artifactHits,
291
- executableArtifactHits,
292
- businessHits,
293
- question,
294
- questionPlan,
295
- blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
296
- excludedArtifactIds,
297
- kg,
298
- })
299
- : null);
328
+ : null;
329
+ const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block' && semanticMetricMatch
330
+ && !hasCertifiedNodeFit(question, questionPlan, catalogCertifiedHit.node)
331
+ ? null
332
+ : catalogCertifiedHit;
333
+ const fallbackCertifiedHit = shouldTryCertifiedRoute ? pickCertifiedArtifact({
334
+ artifactHits,
335
+ executableArtifactHits,
336
+ businessHits,
337
+ question,
338
+ questionPlan,
339
+ blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
340
+ excludedArtifactIds,
341
+ kg,
342
+ }) : null;
343
+ let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
344
+ ?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
345
+ // Certified remains first when it actually covers the question. If the
346
+ // retrieved block does not fit but a governed semantic metric does, never
347
+ // let the broad catalog match pre-empt Lane 2.
300
348
  // A certified TERM / BUSINESS VIEW is documentation, not data: it can be the
301
349
  // terminal answer only for a definition-style question with no data ask. A
302
350
  // question that requests a data shape (dimensions/measures/outputs/top-N) or
@@ -429,8 +477,6 @@ async function runAnswerLoop(input) {
429
477
  // a governed metric can answer. Match by name + synonyms + measure family + hybrid
430
478
  // rank over the FTS semantic hits, then ALL metric KG nodes (revenue ⇄
431
479
  // cumulative_revenue). Certified-first is still preserved (checked above).
432
- const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
433
- let semanticMetricMatch = await matchSemanticMetric(question, semanticMetricNodes).catch(() => null);
434
480
  const clarifyBeforeGeneration = shouldClarifyBeforeGeneration({
435
481
  intent,
436
482
  routeDecision: catalogRoute,
@@ -506,9 +552,27 @@ async function runAnswerLoop(input) {
506
552
  const matchedMetricHit = semanticMetricMatch
507
553
  ? [{ node: semanticMetricMatch.metric, score: Math.max(semanticMetricMatch.score, CERTIFIED_HIT_THRESHOLD) }]
508
554
  : [];
555
+ // Reserve prompt slots per source instead of appending semantic hits LAST and
556
+ // truncating the whole list to 14 — the old behavior dropped semantic-model hits
557
+ // out of the generation prompt entirely whenever artifact/business hits filled
558
+ // the budget first, so even a question a semantic metric could answer never saw
559
+ // the metric. Each group gets a guaranteed minimum; leftover budget is filled
560
+ // round-robin. See interleaveContextHits.
509
561
  const contextHits = activeTier === 'semantic_layer'
510
- ? [...matchedMetricHit, ...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...semanticHits, ...manifestHits].slice(0, 14)
511
- : [...trustedArtifactContext, ...reviewRequiredArtifactHits, ...businessHits.slice(0, 4), ...manifestHits].slice(0, 14);
562
+ ? interleaveContextHits([
563
+ { hits: matchedMetricHit, reserve: matchedMetricHit.length },
564
+ { hits: semanticHits, reserve: 5 },
565
+ { hits: trustedArtifactContext, reserve: 3 },
566
+ { hits: reviewRequiredArtifactHits, reserve: 2 },
567
+ { hits: businessHits.slice(0, 4), reserve: 2 },
568
+ { hits: manifestHits, reserve: 2 },
569
+ ], 14)
570
+ : interleaveContextHits([
571
+ { hits: trustedArtifactContext, reserve: 5 },
572
+ { hits: reviewRequiredArtifactHits, reserve: 3 },
573
+ { hits: businessHits.slice(0, 4), reserve: 3 },
574
+ { hits: manifestHits, reserve: 3 },
575
+ ], 14);
512
576
  const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node));
513
577
  const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
514
578
  const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
@@ -527,7 +591,7 @@ async function runAnswerLoop(input) {
527
591
  messages.push({ role: 'system', content: skillsPrompt });
528
592
  messages.push({
529
593
  role: 'system',
530
- content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, input.contextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget),
594
+ content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, input.contextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget, input.semanticDriver),
531
595
  });
532
596
  messages.push({ role: 'user', content: question });
533
597
  // ── Tier 2: semantic-layer metrics + dimensions (governed hierarchy) ──────
@@ -611,14 +675,55 @@ async function runAnswerLoop(input) {
611
675
  contextPack: input.contextPack,
612
676
  schemaContext,
613
677
  });
678
+ // W2.2 — certified-block adaptation lane. When a certified block is context-only
679
+ // ONLY because the question adds exactly one filter whose value maps to a column
680
+ // the block already outputs, adapt the certified SQL (wrap + filter its result)
681
+ // instead of regenerating from scratch. It executes through the governed path
682
+ // (pre-validated, since the wrapper only restricts an already-certified result)
683
+ // and is labeled BELOW certified. Falls through to generation on any miss.
684
+ let certifiedAdaptation;
685
+ if (!semanticBridgeAnswer && !metricFirst && input.executeGeneratedSql) {
686
+ const fit = input.contextPack?.routeDecision?.blockFit;
687
+ const sourceBlock = input.contextPack?.allowedSqlContext?.sourceBlockSql?.[0];
688
+ if (fit && sourceBlock?.sql) {
689
+ const shape = extractSimpleSelectShape(sourceBlock.sql);
690
+ const blockOutputs = shape
691
+ ? shape.selectExpressions.map((expression) => selectExpressionOutputName(expression)).filter((name) => Boolean(name))
692
+ : [];
693
+ certifiedAdaptation = planCertifiedAdaptation({
694
+ blockFit: fit,
695
+ certifiedSql: sourceBlock.sql,
696
+ blockName: sourceBlock.name,
697
+ blockOutputs,
698
+ resolveFilterColumn: (value) => resolveFilterValueColumns(value, schemaContext),
699
+ }) ?? undefined;
700
+ }
701
+ }
614
702
  let proposed = '';
615
703
  let parsed;
616
704
  const proposalToolCalls = [...semanticBridgeToolCalls];
705
+ // Stage-B toolset: the host's warehouse/validation tools PLUS the answer loop's
706
+ // own governed semantic tools (search_semantic_layer, compile_semantic_query,
707
+ // scan_manifest), so tool-driven generation can compile governed SQL itself and
708
+ // grep the live graph — across every provider (native or text-protocol). Every
709
+ // governed compile is recorded so the answer can be labeled governed, not
710
+ // hand-written, downstream (deriveAgenticTrust).
711
+ const compiledSemanticRecords = [];
712
+ const stageBTools = [
713
+ ...(input.answerLoopTools ?? []),
714
+ ...buildSemanticStageTools({
715
+ semanticLayer: input.semanticLayer,
716
+ kg,
717
+ driver: input.semanticDriver,
718
+ tableMapping: input.semanticTableMapping,
719
+ onCompiled: (record) => compiledSemanticRecords.push(record),
720
+ }),
721
+ ];
617
722
  if (semanticBridgeAnswer) {
618
723
  governedMetricAnswer = true;
619
724
  parsed = {
620
725
  sql: semanticBridgeAnswer.sql,
621
- text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}. This result is uncertified until reviewed and promoted.`,
726
+ text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}. The semantic compiler owns this query; saving it as a reusable certified block still requires review.`,
622
727
  viz: semanticBridgeAnswer.dimensions.length === 0 && !semanticBridgeAnswer.timeDimension ? 'single_value' : undefined,
623
728
  };
624
729
  }
@@ -627,16 +732,39 @@ async function runAnswerLoop(input) {
627
732
  governedMetricAnswer = true;
628
733
  parsed = {
629
734
  sql: metricFirst.sql,
630
- text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}. This result is uncertified until reviewed and promoted.`,
735
+ text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
631
736
  viz: metricFirst.dimensions.length === 0 ? 'single_value' : undefined,
632
737
  };
633
738
  }
739
+ else if (certifiedAdaptation) {
740
+ // Pre-validated governed path (the wrapper only restricts a certified result).
741
+ governedMetricAnswer = true;
742
+ parsed = {
743
+ sql: certifiedAdaptation.sql,
744
+ text: certifiedAdaptation.provenance,
745
+ };
746
+ }
634
747
  else {
748
+ // Tier 2.5 — METRIC-ANCHORED generation. A governed metric matched this question,
749
+ // but its semantic definition couldn't compose the exact requested shape (typically
750
+ // the breakdown needs a join the semantic layer doesn't own — e.g. a location-grain
751
+ // tax metric asked for "by product"). Rather than throw the metric away and let the
752
+ // model reinvent the aggregation from scratch, inject the metric's CERTIFIED
753
+ // definition as a required building block, so the generated SQL reuses the trusted
754
+ // measure and only generates the join/grouping around it. Keeps the number
755
+ // consistent with the governed metric. Degrades to plain generation when the metric
756
+ // has no resolvable definition. The grain ledger + validation still gate the output.
757
+ const metricAnchor = semanticMetricMatch && input.semanticLayer
758
+ ? resolveGovernedMetricDefinition(semanticMetricMatch.metric, semanticMetricNodes, input.semanticLayer)
759
+ : undefined;
760
+ const generationMessages = metricAnchor
761
+ ? [...messages, { role: 'system', content: metricAnchorInstruction(metricAnchor.metric.name, metricAnchor.def) }]
762
+ : messages;
635
763
  try {
636
764
  proposed = await generateProposalWithOptionalTools({
637
765
  provider,
638
- messages,
639
- tools: input.answerLoopTools,
766
+ messages: generationMessages,
767
+ tools: stageBTools,
640
768
  questionPlan,
641
769
  intent,
642
770
  signal: input.signal,
@@ -676,6 +804,35 @@ async function runAnswerLoop(input) {
676
804
  };
677
805
  }
678
806
  parsed = parseProposal(proposed);
807
+ // Surface the tier choice (WS3): when generation was anchored on a governed
808
+ // metric, say so, so the answer reads as "reused the certified measure" rather
809
+ // than an opaque hand-written query.
810
+ if (metricAnchor && parsed.sql) {
811
+ const anchorNote = `Computed using the governed metric ${metricAnchor.metric.name}'s certified definition, joined to add the requested breakdown. Review-required (not itself certified).`;
812
+ parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${anchorNote}` : anchorNote };
813
+ }
814
+ // Stage-B governed promotion: when the model drove compile_semantic_query and
815
+ // adopted its output VERBATIM (deriveAgenticTrust — exact match only), the SQL
816
+ // is governed semantic SQL the compiler owns and already validated, not
817
+ // hand-written. Treat it like the deterministic governed path: skip the
818
+ // hallucination guard (governedMetricAnswer) and note the provenance. Any edit
819
+ // to the compiled SQL falls back to generated/review-required by construction.
820
+ if (!governedMetricAnswer && parsed.sql) {
821
+ const trust = deriveAgenticTrust(parsed.sql, compiledSemanticRecords);
822
+ if (trust.tier === 'semantic_metric' && trust.compiled) {
823
+ governedMetricAnswer = true;
824
+ const compiled = trust.compiled;
825
+ const governedNote = `Answered from governed semantic metric${compiled.metrics.length === 1 ? '' : 's'} ${compiled.metrics.join(', ')}${compiled.dimensions.length > 0 ? ` by ${compiled.dimensions.join(', ')}` : ''} (compiled via the semantic layer). Reusable block certification remains a separate review.`;
826
+ parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${governedNote}` : governedNote };
827
+ proposalToolCalls.push({
828
+ name: 'compile_semantic_query',
829
+ status: 'checked',
830
+ inputSummary: `metrics: ${compiled.metrics.join(', ')}${compiled.dimensions.length ? `; by ${compiled.dimensions.join(', ')}` : ''}`,
831
+ outputSummary: 'Model-selected semantic members compiled to governed SQL',
832
+ order: proposalToolCalls.length + 1,
833
+ });
834
+ }
835
+ }
679
836
  }
680
837
  let deepCandidateResult;
681
838
  let deepCandidateExecutionError;
@@ -692,6 +849,7 @@ async function runAnswerLoop(input) {
692
849
  executeGeneratedSql: input.executeGeneratedSql,
693
850
  signal: input.signal,
694
851
  reasoningEffort: input.reasoningEffort,
852
+ maxAlternatives: deepAlternativeCountForQuestion(questionPlan, intent),
695
853
  });
696
854
  if (selection.selected) {
697
855
  proposed = selection.selected.raw;
@@ -725,7 +883,7 @@ async function runAnswerLoop(input) {
725
883
  parsed = {
726
884
  sql: resolved.sql,
727
885
  text: parsed.text ||
728
- `Answered from the governed metric ${resolved.metric.name}. This result is uncertified until reviewed and promoted.`,
886
+ `Answered from the governed metric ${resolved.metric.name}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
729
887
  viz: parsed.viz ?? 'single_value',
730
888
  };
731
889
  }
@@ -751,7 +909,7 @@ async function runAnswerLoop(input) {
751
909
  proposed = await generateProposalWithOptionalTools({
752
910
  provider,
753
911
  messages: [...messages, { role: 'system', content: FORCE_JOIN_INSTRUCTION }],
754
- tools: input.answerLoopTools,
912
+ tools: stageBTools,
755
913
  questionPlan,
756
914
  intent,
757
915
  signal: input.signal,
@@ -959,6 +1117,21 @@ async function runAnswerLoop(input) {
959
1117
  // Repair is best-effort — the refusal below stays the honest fallback.
960
1118
  }
961
1119
  }
1120
+ // W1.3 — deterministic, warn-only fan-out check. When the generated SQL aggregates
1121
+ // an additive measure across a one-to-many join (grain ledger knows the keys), the
1122
+ // number can silently double-count. Surface a caution so it is reviewed. This never
1123
+ // blocks: it only appends to warnings, and it is conservative (no key data ⇒ no flag).
1124
+ if (contextValidation.ok && parsed.sql && input.contextPack?.objects?.length) {
1125
+ try {
1126
+ const fanoutWarnings = fanoutWarningsForSql(parsed.sql, input.contextPack.objects);
1127
+ if (fanoutWarnings.length > 0) {
1128
+ contextValidation = { ok: true, warnings: [...contextValidation.warnings, ...fanoutWarnings] };
1129
+ }
1130
+ }
1131
+ catch {
1132
+ // Fan-out detection is advisory; any failure must not affect the answer.
1133
+ }
1134
+ }
962
1135
  if (!contextValidation.ok) {
963
1136
  const text = `I could not safely prepare this review-required DQL artifact from the inspected context. The SQL preview failed validation: ${contextValidation.error}`;
964
1137
  const analysisPlan = buildAnalysisPlan({
@@ -1239,26 +1412,32 @@ async function runAnswerLoop(input) {
1239
1412
  ? { ...dqlArtifact, sourcePath: dqlArtifact.sourcePath ?? draftBlock.path }
1240
1413
  : dqlArtifact;
1241
1414
  const sourceCertifiedBlock = followUpSourceBlock?.name ?? input.followUp?.sourceBlockName;
1242
- const trustExplanation = generatedTrustExplanation({
1243
- followUp: input.followUp,
1244
- sourceCertifiedBlock,
1245
- draftBlock,
1246
- });
1415
+ const trustExplanation = governedMetricAnswer
1416
+ ? undefined
1417
+ : generatedTrustExplanation({
1418
+ followUp: input.followUp,
1419
+ sourceCertifiedBlock,
1420
+ draftBlock,
1421
+ });
1247
1422
  const cleanedSummary = cleanGeneratedSummary(parsed.text);
1248
1423
  const generatedText = [partialShapeWarning, trustExplanation, cleanedSummary]
1249
1424
  .filter(Boolean)
1250
1425
  .join('\n\n');
1251
- const semanticMetricCertification = governedMetricAnswer && activeTier === 'semantic_layer'
1426
+ // A metric can be resolved directly from the loaded semantic layer even if
1427
+ // the small metadata pack happened to contain only dbt objects. The answer
1428
+ // is still compiler-owned semantic SQL in that case; do not accidentally
1429
+ // downgrade its route/trust to generated SQL because of retrieval order.
1430
+ const semanticMetricCertification = governedMetricAnswer
1252
1431
  ? semanticMetricMatch?.metric.certification
1253
1432
  : undefined;
1254
1433
  const certifiedMetricAnswer = semanticMetricCertification === 'certified' || semanticMetricCertification === 'reviewed';
1255
1434
  return {
1256
1435
  kind: 'uncertified',
1257
- sourceTier: activeTier,
1258
- certification: 'ai_generated',
1259
- reviewStatus: 'draft_ready',
1436
+ sourceTier: governedMetricAnswer ? 'semantic_layer' : activeTier,
1437
+ certification: governedMetricAnswer ? 'governed' : 'ai_generated',
1438
+ reviewStatus: governedMetricAnswer ? 'governed' : 'draft_ready',
1260
1439
  semanticMetricCertification,
1261
- confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer && activeTier === 'semantic_layer' ? 0.72 : 0.55,
1440
+ confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
1262
1441
  text: generatedText,
1263
1442
  answer: generatedText,
1264
1443
  proposedSql: parsed.sql,
@@ -1308,7 +1487,7 @@ async function runAnswerLoop(input) {
1308
1487
  providerUsed: provider.name,
1309
1488
  // Carry the governed metric match so the exit point can name a
1310
1489
  // `semantic_metric` route (spec 17, part C).
1311
- _semanticMetricMatch: governedMetricAnswer && activeTier === 'semantic_layer' ? semanticMetricMatch ?? undefined : undefined,
1490
+ _semanticMetricMatch: governedMetricAnswer ? semanticMetricMatch ?? undefined : undefined,
1312
1491
  };
1313
1492
  }
1314
1493
  function trimResultToRequestedTopN(result, plan) {
@@ -1353,13 +1532,21 @@ Rules:
1353
1532
  separate SQL code fence when using JSON. The optional "dql" object is
1354
1533
  metadata for the deterministic draft-DQL renderer; do not hand-write a full
1355
1534
  DQL block.
1535
+ Design the SELECT for a business reader: prefer a joined/display name, label,
1536
+ title, or business value over raw *_id, *_uuid, *_key, or technical codes.
1537
+ Alias calculated outputs with clear business names. Include identifiers only
1538
+ when the question asks for them or no readable field exists.
1356
1539
  4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
1357
1540
  measures and how they aggregate, the dimensions/filters, and the exact join
1358
1541
  path + join keys between the grounded tables. Then make the SQL match that
1359
1542
  plan — an explicit grain and join path prevents wrong-grain answers and
1360
1543
  fan-out (row-multiplying) joins.
1361
- 5. Use a visualization type from this list in the "viz" JSON field: line, bar,
1362
- area, pie, single_value, table, pivot, kpi.
1544
+ 5. Choose a visualization deliberately in the "viz" JSON field: use single_value
1545
+ or kpi for one aggregate, line/area only for an ordered time series, bar for a
1546
+ categorical comparison, grouped-bar for multiple measures by one category,
1547
+ scatter for two continuous measures, and table when the result is not chartable.
1548
+ Do not default to bar. The runtime validates this preference against returned
1549
+ rows before displaying it.
1363
1550
  6. NEVER fabricate column names that are not present in the supplied schema,
1364
1551
  dbt metadata, or certified source SQL shape context. If a requested filter
1365
1552
  value is supplied as a matched value, prefer the table and column that
@@ -1368,15 +1555,19 @@ Rules:
1368
1555
  Do NOT use dbt/Jinja macros such as {{ ref(...) }} or {{ source(...) }} in
1369
1556
  proposed SQL. Do not emit INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, COPY,
1370
1557
  PRAGMA, SET, or multiple statements.
1371
- 8. If the schema is insufficient to answer, say so explicitly and ask a
1372
- clarifying question instead of guessing. But a MULTI-ENTITY question ("top
1373
- customers who bought the top products", "accounts with the most overdue
1374
- invoices") is NOT insufficient context when the grounded tables and a join
1375
- route between them are supplied (see any "Knowledge graph join routes"
1376
- section). In that case you MUST compose the joined SELECT that answers it
1377
- directly never refuse it or offer to show the parts as separate datasets.
1378
- Reserve the clarifying question for a genuinely absent table, column, or join
1379
- key.
1558
+ 8. If the schema is insufficient, DISCOVER before you decline: use
1559
+ search_metadata to find candidate relations, get_table_schema to confirm a
1560
+ relation's real columns and inferred join keys, and expand_context for a
1561
+ relation you already know by name. Only ask a clarifying question once those
1562
+ come up empty AND the question is genuinely ambiguous never decline just
1563
+ because the initially supplied context pack didn't already include the table.
1564
+ A MULTI-ENTITY question ("top customers who bought the top products", "accounts
1565
+ with the most overdue invoices") is NOT insufficient context when the grounded
1566
+ tables and a join route between them are supplied (see any "Knowledge graph
1567
+ join routes" section) or discoverable via the tools above. In that case you
1568
+ MUST compose the joined SELECT that answers it directly — never refuse it or
1569
+ offer to show the parts as separate datasets. Reserve the clarifying question
1570
+ for a genuinely absent table, column, or join key.
1380
1571
  9. Write directly to the analyst. Do not say "the user is asking", "the user
1381
1572
  requested", "I will generate", or describe internal routing. State the
1382
1573
  answer, the certified context used, the DQL artifact expectation, and the
@@ -1390,7 +1581,20 @@ Rules:
1390
1581
  // combined dataset — show them separately" refusal into the join the user asked
1391
1582
  // for, while still allowing an honest refusal if context is truly missing.
1392
1583
  const FORCE_JOIN_INSTRUCTION = `Your previous attempt declined to produce SQL. Re-read the supplied schema, metadata, and any "Knowledge graph join routes": this question CAN be answered by joining the grounded tables along their documented keys. Do NOT refuse, and do NOT suggest showing the datasets separately — compose ONE read-only SELECT/WITH that joins the relevant tables to answer it directly, following the JSON contract from rule 3. State the grain and the exact join path in the summary. Only if a required table, column, or join key is truly absent from the supplied context may you still ask a clarifying question.`;
1393
- function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET) {
1584
+ // Metric-anchored generation (Tier 2.5): a governed metric matched the question but
1585
+ // the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
1586
+ // definition as the measure rather than reinventing it, so the number stays consistent
1587
+ // with the governed metric — only the join/grouping around it is generated.
1588
+ function metricAnchorInstruction(metricName, def) {
1589
+ return `A GOVERNED metric matched this question, but the semantic layer could not compose the exact requested shape on its own (the breakdown likely needs a join the metric's table does not own). You MUST compute the measure using this certified definition — do NOT redefine, rename, or approximate it:\n metric "${metricName}": ${def.expr} (defined over ${def.table})\nCompose ONE read-only SELECT/WITH that computes exactly this expression as the measure, joining ${def.table} to the other grounded tables along their documented keys to add the requested dimensions, then GROUP BY those dimensions. State the join path and grain in the summary. Reusing the governed definition keeps the answer consistent with the certified metric; only fall back to redefining the measure if ${def.table} genuinely cannot be joined to the requested breakdown.`;
1590
+ }
1591
+ function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET, driver) {
1592
+ // W1.5 — dialect conventions so the model writes warehouse-correct SQL
1593
+ // (quoting, row-limiting, date functions) instead of a DuckDB/Postgres default
1594
+ // that fails on Snowflake/BigQuery/etc. Only emitted when a driver is known.
1595
+ const dialectSection = driver
1596
+ ? `\n\n## SQL dialect\n\n${describeDialectForPrompt(driver)}`
1597
+ : '';
1394
1598
  const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
1395
1599
  ? 'Use a certified artifact only if it exactly answers the question.'
1396
1600
  : 'Prepare a review-required DQL artifact with a SQL preview for this question. Certified blocks are trusted context, not a reason to answer the wrong grain.'}`;
@@ -1459,7 +1663,7 @@ function renderContextPrompt(blocks, businessContext, others, activeTier, memory
1459
1663
  const contextPackSection = contextPack
1460
1664
  ? `\n\n## Local metadata context pack\n\n${renderContextPackForPrompt(contextPack, budget)}`
1461
1665
  : '';
1462
- return `${intentSection}${budgetSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
1666
+ return `${intentSection}${budgetSection}${dialectSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
1463
1667
  }
1464
1668
  function buildKgJoinPathHints(kg, nodes, questionPlan) {
1465
1669
  const candidates = nodes
@@ -2968,25 +3172,29 @@ async function generateProposalWithOptionalTools(input) {
2968
3172
  signal: input.signal,
2969
3173
  reasoningEffort: input.reasoningEffort,
2970
3174
  };
2971
- if (!input.provider.generateWithTools || tools.length === 0) {
3175
+ // No tools → plain generation (nothing for the loop to drive).
3176
+ if (tools.length === 0) {
2972
3177
  return input.provider.generate(input.messages, options);
2973
3178
  }
2974
3179
  const toolBudget = proposalToolBudgetForQuestion(input.questionPlan, input.intent, {
2975
3180
  analysisDepth: input.analysisDepth,
2976
3181
  reasoningEffort: input.reasoningEffort,
2977
3182
  });
2978
- const toolPolicy = {
2979
- role: 'system',
2980
- content: [
2981
- 'You may use the supplied DQL tools to inspect semantic members, certified context, metadata context, and bounded repair options.',
2982
- `Tool budget for this question: ${toolBudget.maxToolCalls} call(s) (${toolBudget.effortClass}: ${toolBudget.reason}). Stop as soon as a lane can answer.`,
2983
- 'Prefer semantic compile before deep warehouse search when the semantic layer contains the requested metric/dimensions/time grain.',
2984
- 'Use context expansion only to repair a named, known relation/column gap; do not loop on the same failed context.',
2985
- 'Final response must be a single ```json fenced object with summary, sql, viz, outputs, and optional dql metadata fields.',
2986
- ].join('\n'),
2987
- };
2988
- return input.provider.generateWithTools([...input.messages, toolPolicy], tools, {
3183
+ const toolPolicy = [
3184
+ 'You may use the supplied DQL tools to inspect semantic members, certified context, metadata context, and bounded repair options.',
3185
+ `Tool budget for this question: ${toolBudget.maxToolCalls} call(s) (${toolBudget.effortClass}: ${toolBudget.reason}). Stop as soon as a lane can answer.`,
3186
+ 'Prefer a governed semantic compile (search_semantic_layer compile_semantic_query) over hand-written SQL when the semantic layer contains the requested metric/dimensions/time grain.',
3187
+ 'When the supplied context is missing a table, column, or join key, DISCOVER it with `search_metadata` (find candidate relations) then `get_table_schema` (confirm real columns + inferred join keys) before declining. Use `search_project_files` for a bounded live source grep when indexed retrieval missed an identifier; use `scan_manifest` for cached graph objects; do not loop on the same failed context.',
3188
+ 'When unsure a relation/column exists, validate a composed query with `validate_sql` rather than guessing.',
3189
+ 'Final response must be a single ```json fenced object with summary, sql, viz, outputs, and optional dql metadata fields.',
3190
+ ].join('\n');
3191
+ // runAgenticToolLoop drives the loop over ANY provider: native tool use where the
3192
+ // provider implements generateWithTools (Claude/OpenAI), and an equivalent text
3193
+ // protocol otherwise (subscription-CLI passthrough, Ollama). This is what gives
3194
+ // every provider — not just the two API ones — a real tool-driven Stage B.
3195
+ return runAgenticToolLoop(input.provider, [...input.messages], tools, {
2989
3196
  ...options,
3197
+ toolPolicy,
2990
3198
  maxToolCalls: toolBudget.maxToolCalls,
2991
3199
  onToolCall: (event) => {
2992
3200
  const sink = input.toolCalls;
@@ -3130,8 +3338,14 @@ async function generateDeepAlternativeProposals(input) {
3130
3338
  'Create a fourth candidate by DECOMPOSITION: break the question into sub-questions, solve each as a CTE, then compose the final SELECT — avoiding assumptions hidden in the first candidate.',
3131
3339
  ];
3132
3340
  const temperatures = [0.2, 0.35, 0.5];
3133
- const out = [];
3134
- for (const [index, instruction] of variants.entries()) {
3341
+ // How many diverse alternatives to generate is set by the question SHAPE (S1):
3342
+ // a lightweight 1-candidate agreement check for join/breakdown shapes, the full
3343
+ // 3-candidate vote for deep-research, none for a single-table lookup. The
3344
+ // generations are independent, so they run in PARALLEL — the deep vote's
3345
+ // wall-clock cost is one generation, not the serial sum of all of them.
3346
+ const count = Math.max(0, Math.min(input.maxAlternatives ?? variants.length, variants.length));
3347
+ const selected = variants.slice(0, count);
3348
+ const results = await Promise.all(selected.map(async (instruction, index) => {
3135
3349
  try {
3136
3350
  const raw = await input.provider.generate([
3137
3351
  ...input.messages,
@@ -3151,14 +3365,14 @@ async function generateDeepAlternativeProposals(input) {
3151
3365
  reasoningEffort: input.reasoningEffort,
3152
3366
  temperature: temperatures[index] ?? 0.4,
3153
3367
  });
3154
- if (raw.trim())
3155
- out.push(raw);
3368
+ return raw.trim() ? raw : '';
3156
3369
  }
3157
3370
  catch {
3158
3371
  // Alternative candidates are opportunistic; the initial candidate remains.
3372
+ return '';
3159
3373
  }
3160
- }
3161
- return out;
3374
+ }));
3375
+ return results.filter((raw) => raw.trim());
3162
3376
  }
3163
3377
  function cloneParsedProposal(proposal) {
3164
3378
  return {
@@ -3190,6 +3404,7 @@ function evidenceToolCallFromEvent(event, order) {
3190
3404
  inputSummary: summarizeEvidencePayload(event.input),
3191
3405
  outputSummary: summarizeEvidencePayload(event.output),
3192
3406
  order,
3407
+ ...(typeof event.durationMs === 'number' ? { durationMs: event.durationMs } : {}),
3193
3408
  };
3194
3409
  }
3195
3410
  function summarizeEvidencePayload(value, maxLength = 700) {
@@ -3295,6 +3510,40 @@ function certifiedFreshnessCaveat(artifact) {
3295
3510
  return undefined;
3296
3511
  }
3297
3512
  }
3513
+ function provenanceSourceTierLabel(tier) {
3514
+ switch (tier) {
3515
+ case 'certified_artifact': return 'Certified block';
3516
+ case 'semantic_layer': return 'Governed semantic metric';
3517
+ case 'business_context': return 'Business context';
3518
+ case 'dbt_manifest': return 'Generated SQL';
3519
+ default: return undefined;
3520
+ }
3521
+ }
3522
+ /**
3523
+ * Anthropic-style provenance footer: one line telling a stakeholder WHERE the
3524
+ * answer came from (source tier), how much to trust it, who owns the source, and
3525
+ * whether the underlying data is current. Built once at the single exit from the
3526
+ * finished answer; omitted for no-answer outcomes.
3527
+ */
3528
+ export function buildProvenanceFooter(result, trust) {
3529
+ if (result.kind === 'no_answer')
3530
+ return undefined;
3531
+ const parts = [];
3532
+ const tierLabel = provenanceSourceTierLabel(result.sourceTier);
3533
+ if (tierLabel)
3534
+ parts.push(`Source: ${tierLabel}`);
3535
+ if (trust?.display)
3536
+ parts.push(`Trust: ${trust.display}`);
3537
+ if (result.block?.owner)
3538
+ parts.push(`Owner: ${result.block.owner}`);
3539
+ const freshness = result.block?.dataState === 'stale' ? 'stale — verify currency'
3540
+ : result.block?.dataState === 'failed' ? 'upstream run failed'
3541
+ : result.block?.dataState === 'fresh' ? 'current'
3542
+ : undefined;
3543
+ if (freshness)
3544
+ parts.push(`Data: ${freshness}`);
3545
+ return parts.length > 0 ? parts.join(' · ') : undefined;
3546
+ }
3298
3547
  function mergeHits(...groups) {
3299
3548
  const byId = new Map();
3300
3549
  for (const group of groups) {
@@ -3316,6 +3565,53 @@ function mergeNodes(...groups) {
3316
3565
  }
3317
3566
  return Array.from(byId.values());
3318
3567
  }
3568
+ /**
3569
+ * Merge prioritized hit groups into a single deduped list under a total budget,
3570
+ * giving each group a guaranteed minimum number of slots. Pass 1 takes up to
3571
+ * `reserve` from each group in priority order; pass 2 fills any remaining budget
3572
+ * round-robin from each group's leftovers. This prevents a high-priority group
3573
+ * from starving a lower one out of the prompt entirely (the semantic-hit
3574
+ * truncation bug) while still honoring priority for the reserved slots.
3575
+ */
3576
+ function interleaveContextHits(groups, budget) {
3577
+ const seen = new Set();
3578
+ const out = [];
3579
+ const cursors = groups.map(() => 0);
3580
+ const take = (hit) => {
3581
+ if (out.length >= budget || seen.has(hit.node.nodeId))
3582
+ return false;
3583
+ seen.add(hit.node.nodeId);
3584
+ out.push(hit);
3585
+ return true;
3586
+ };
3587
+ // Pass 1: reserved minimums, in priority order.
3588
+ groups.forEach((group, gi) => {
3589
+ let taken = 0;
3590
+ while (cursors[gi] < group.hits.length && taken < group.reserve && out.length < budget) {
3591
+ if (take(group.hits[cursors[gi]]))
3592
+ taken += 1;
3593
+ cursors[gi] += 1;
3594
+ }
3595
+ });
3596
+ // Pass 2: round-robin fill from leftovers until the budget is exhausted.
3597
+ let progressed = true;
3598
+ while (out.length < budget && progressed) {
3599
+ progressed = false;
3600
+ for (let gi = 0; gi < groups.length && out.length < budget; gi += 1) {
3601
+ const group = groups[gi];
3602
+ while (cursors[gi] < group.hits.length) {
3603
+ const before = out.length;
3604
+ take(group.hits[cursors[gi]]);
3605
+ cursors[gi] += 1;
3606
+ if (out.length > before) {
3607
+ progressed = true;
3608
+ break;
3609
+ }
3610
+ }
3611
+ }
3612
+ }
3613
+ return out;
3614
+ }
3319
3615
  /**
3320
3616
  * Candidate metric KG nodes for semantic-metric matching (spec 17, part C).
3321
3617
  * Starts with the FTS semantic + considered hits, then folds in EVERY metric