@duckcodeailabs/dql-cli 1.12.3 → 1.12.5

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 (79) hide show
  1. package/dist/assets/dql-notebook/assets/AgentAnswerCard-BJAnjaLA.js +5 -0
  2. package/dist/assets/dql-notebook/assets/{AgentLogPage-CsSC2tOR.js → AgentLogPage-J72WR6hk.js} +1 -1
  3. package/dist/assets/dql-notebook/assets/{AiBuildDialog-CjJ-9bQW.js → AiBuildDialog-CHJZfDmp.js} +1 -1
  4. package/dist/assets/dql-notebook/assets/{AiBuildResult-BvTcW9oE.js → AiBuildResult-Da7_Cn12.js} +1 -1
  5. package/dist/assets/dql-notebook/assets/{AiSidePanel-CxFrOfCE.js → AiSidePanel-BY6W_5aC.js} +1 -1
  6. package/dist/assets/dql-notebook/assets/{AnalyticsHome-BtXEdJNR.js → AnalyticsHome-u-buXgdv.js} +1 -1
  7. package/dist/assets/dql-notebook/assets/{AppsView-HjQkbbE6.js → AppsView-3k7ftDpd.js} +3 -3
  8. package/dist/assets/dql-notebook/assets/{BlockStudio-oQHTJsXC.js → BlockStudio-CLX4EUyO.js} +14 -14
  9. package/dist/assets/dql-notebook/assets/{BusinessArtifactView-Dpsftjs7.js → BusinessArtifactView-3THMNeyO.js} +1 -1
  10. package/dist/assets/dql-notebook/assets/{DbtFirstModelingPage-c6ZteaI6.js → DbtFirstModelingPage-BwBPwtdA.js} +1 -1
  11. package/dist/assets/dql-notebook/assets/{GitPage-Cj6mc-Yv.js → GitPage-CvEki0pe.js} +1 -1
  12. package/dist/assets/dql-notebook/assets/{GlobalAiRail-8G_rEyhK.js → GlobalAiRail-NXdk9kNy.js} +1 -1
  13. package/dist/assets/dql-notebook/assets/{GovernedContextPage-BTtzt0Za.js → GovernedContextPage-DILbw-Vr.js} +1 -1
  14. package/dist/assets/dql-notebook/assets/{HelpDocsPage-e2CesE73.js → HelpDocsPage-DgCu4bvY.js} +1 -1
  15. package/dist/assets/dql-notebook/assets/{HomePage-U3ryMLEF.js → HomePage-CK3fXTQo.js} +1 -1
  16. package/dist/assets/dql-notebook/assets/{LineageDAG-CEmgJzm3.js → LineageDAG-DW_NO3P2.js} +1 -1
  17. package/dist/assets/dql-notebook/assets/{LineageDetailView-CeV5QMXS.js → LineageDetailView-VT7DLJOB.js} +1 -1
  18. package/dist/assets/dql-notebook/assets/{LineageDrawer-B1oAn-N_.js → LineageDrawer-DSTm8aXw.js} +1 -1
  19. package/dist/assets/dql-notebook/assets/{LineagePathBreadcrumb-fP6RTXut.js → LineagePathBreadcrumb-DRlAYasJ.js} +1 -1
  20. package/dist/assets/dql-notebook/assets/{MiniLineageGraph-CBYSTmRC.js → MiniLineageGraph-CR_nhwhZ.js} +1 -1
  21. package/dist/assets/dql-notebook/assets/{NewBlockModal-ksEwimjl.js → NewBlockModal-CJvjYiBF.js} +1 -1
  22. package/dist/assets/dql-notebook/assets/{NewNotebookModal-CUx0WmXb.js → NewNotebookModal-BEpPH93n.js} +1 -1
  23. package/dist/assets/dql-notebook/assets/{NotebookEditor-BdxJL3LX.js → NotebookEditor-C7GDFwhF.js} +1 -1
  24. package/dist/assets/dql-notebook/assets/{ReadinessPage-DZ9wDW76.js → ReadinessPage-rZiOp2xr.js} +1 -1
  25. package/dist/assets/dql-notebook/assets/{SetupOnboarding-Bt0AAe0h.js → SetupOnboarding-BWjhTjPB.js} +1 -1
  26. package/dist/assets/dql-notebook/assets/{TrustBadge-DNVJK_mP.js → TrustBadge-RvZ9s6OQ.js} +1 -1
  27. package/dist/assets/dql-notebook/assets/{UnifiedAgentRunPanel-BNqE169i.js → UnifiedAgentRunPanel-CGPcpY1B.js} +18 -18
  28. package/dist/assets/dql-notebook/assets/{answer-to-notebook-B_gE6rsY.js → answer-to-notebook-Di227Cd4.js} +1 -1
  29. package/dist/assets/dql-notebook/assets/{arrow-left-DnKGhzOR.js → arrow-left-BZHDcWHR.js} +1 -1
  30. package/dist/assets/dql-notebook/assets/{arrow-right-CQ73DG7_.js → arrow-right-DlM1pC3_.js} +1 -1
  31. package/dist/assets/dql-notebook/assets/{book-open-text-CyBV5Nhw.js → book-open-text-DBcXFQAu.js} +1 -1
  32. package/dist/assets/dql-notebook/assets/{bot-UOPtBzCj.js → bot-BAywbvPm.js} +1 -1
  33. package/dist/assets/dql-notebook/assets/{chevron-up-CDHDU3N3.js → chevron-up-B_GfKE_F.js} +1 -1
  34. package/dist/assets/dql-notebook/assets/{circle-x-DC5nkL_u.js → circle-x-BcxtZ-ym.js} +1 -1
  35. package/dist/assets/dql-notebook/assets/{dagre.esm-C7ujXipB.js → dagre.esm-Bqs0lsEz.js} +1 -1
  36. package/dist/assets/dql-notebook/assets/{external-link-BiJ_dtTM.js → external-link-B24Dk9_t.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/{grip-vertical-eXvkUerq.js → grip-vertical-D9g31N3j.js} +1 -1
  38. package/dist/assets/dql-notebook/assets/index-DU7j3Sqk.js +644 -0
  39. package/dist/assets/dql-notebook/assets/{link-2-DkHVJTuH.js → link-2-B36oR5Bj.js} +1 -1
  40. package/dist/assets/dql-notebook/assets/{list-tree-8lYW2Itb.js → list-tree-B9ZzGc4U.js} +1 -1
  41. package/dist/assets/dql-notebook/assets/{maximize-2-DjSovOdP.js → maximize-2-BrvQLPoj.js} +1 -1
  42. package/dist/assets/dql-notebook/assets/{minimize-2-DGlmQLES.js → minimize-2-BrpFmsxS.js} +1 -1
  43. package/dist/assets/dql-notebook/assets/{panel-right-open-DPc4gShM.js → panel-right-open-B1g1qhh5.js} +1 -1
  44. package/dist/assets/dql-notebook/assets/{pencil-nwaGtOdL.js → pencil-DwywbTqQ.js} +1 -1
  45. package/dist/assets/dql-notebook/assets/{rotate-ccw-ByjLhAu7.js → rotate-ccw-Db_KVs3J.js} +1 -1
  46. package/dist/assets/dql-notebook/assets/{semantic-fields-eEhwnQhG.js → semantic-fields-BIVuvP2l.js} +1 -1
  47. package/dist/assets/dql-notebook/assets/{triangle-alert-zws0cKhp.js → triangle-alert-Cne6C2aG.js} +1 -1
  48. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-D6mtrJ-_.js +18 -0
  49. package/dist/assets/dql-notebook/assets/{user-round-CM8xNakq.js → user-round-DkzxN1xD.js} +1 -1
  50. package/dist/assets/dql-notebook/assets/{wand-sparkles-CIKc8r-4.js → wand-sparkles-CJUvFtur.js} +1 -1
  51. package/dist/assets/dql-notebook/assets/{workflow-6C0ZLbTi.js → workflow-BOvVTg7R.js} +1 -1
  52. package/dist/assets/dql-notebook/assets/{wrench-CeU7hF12.js → wrench-YeSpzZRt.js} +1 -1
  53. package/dist/assets/dql-notebook/assets/{x-DLWEzvri.js → x-C6tsHUR7.js} +1 -1
  54. package/dist/assets/dql-notebook/index.html +1 -1
  55. package/dist/block-invocation.js +4 -0
  56. package/dist/block-invocation.js.map +1 -1
  57. package/dist/llm/answer-loop-tools.d.ts +8 -2
  58. package/dist/llm/answer-loop-tools.d.ts.map +1 -1
  59. package/dist/llm/answer-loop-tools.js +29 -2
  60. package/dist/llm/answer-loop-tools.js.map +1 -1
  61. package/dist/llm/providers/dql-agent-provider.d.ts +27 -1
  62. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  63. package/dist/llm/providers/dql-agent-provider.js +79 -15
  64. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  65. package/dist/llm/types.d.ts +8 -1
  66. package/dist/llm/types.d.ts.map +1 -1
  67. package/dist/local-runtime.d.ts +46 -0
  68. package/dist/local-runtime.d.ts.map +1 -1
  69. package/dist/local-runtime.js +647 -66
  70. package/dist/local-runtime.js.map +1 -1
  71. package/dist/package.json +10 -10
  72. package/dist/sql-result-filter.d.ts +39 -0
  73. package/dist/sql-result-filter.d.ts.map +1 -0
  74. package/dist/sql-result-filter.js +256 -0
  75. package/dist/sql-result-filter.js.map +1 -0
  76. package/package.json +10 -10
  77. package/dist/assets/dql-notebook/assets/AgentAnswerCard-BEIwKGm4.js +0 -5
  78. package/dist/assets/dql-notebook/assets/index-BMVfPaXr.js +0 -644
  79. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-hxi1RZDm.js +0 -18
@@ -20,7 +20,8 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
20
20
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
21
21
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
22
22
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
23
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
23
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, analyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
24
+ import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
24
25
  import { gatherProposeEnrichment } from './propose-enrich.js';
25
26
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
26
27
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -435,6 +436,9 @@ async function conversationContextFromThread(store, threadId, clientContext, que
435
436
  trustLabel: turn.trustLabel,
436
437
  runStatus: turn.runStatus,
437
438
  stopReason: turn.stopReason,
439
+ // Carried so every consumer can compute trust from the same fields.
440
+ refusalCode: turn.refusalCode,
441
+ executionError: turn.executionError,
438
442
  certification: turn.certification,
439
443
  contextPackId: turn.contextPackId,
440
444
  dqlArtifact: turn.dqlArtifact,
@@ -473,9 +477,30 @@ async function conversationContextFromThread(store, threadId, clientContext, que
473
477
  ...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
474
478
  ...(serverSnapshot ? { serverSnapshot } : {}),
475
479
  ...(thread?.rollingSummary ? { conversationSummary: thread.rollingSummary } : {}),
476
- ...(turns.length > 0 ? { turns, activeTurnId: turns[turns.length - 1].id } : {}),
480
+ // `activeTurnId` is the FOLLOW-UP ANCHOR: the turn whose filters, prior DQL
481
+ // artifact, and source SQL the next question builds on. Anchoring it to the
482
+ // last turn unconditionally meant a failed turn became the anchor, and its
483
+ // broken artifact and failing SQL were handed to the next question as
484
+ // authoritative context — the sticky-error loop. `latestTurnId` keeps the
485
+ // real tail for presentation.
486
+ ...(turns.length > 0
487
+ ? {
488
+ turns,
489
+ activeTurnId: lastTrustedTurnId(turns) ?? undefined,
490
+ latestTurnId: turns[turns.length - 1].id,
491
+ }
492
+ : {}),
477
493
  };
478
494
  }
495
+ /** The most recent turn a follow-up may safely build on. */
496
+ function lastTrustedTurnId(turns) {
497
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
498
+ const turn = turns[index];
499
+ if (isTrustedConversationTurn(turn))
500
+ return turn.id;
501
+ }
502
+ return undefined;
503
+ }
479
504
  /** Best-effort: persist a completed run as a conversation turn (never throws). */
480
505
  function recordConversationTurn(store, threadId, run) {
481
506
  if (!store || !threadId)
@@ -489,8 +514,12 @@ function recordConversationTurn(store, threadId, run) {
489
514
  // turn folding its (possibly misparsed) filters and measures into working
490
515
  // state poisoned every subsequent ask: the SAME question that worked once
491
516
  // then failed forever because the bad turn's phantom state carried forward.
492
- const producedAnswer = run.status === 'completed' || run.status === 'needs_review';
493
- if (producedAnswer)
517
+ //
518
+ // The previous guard tested `run.status`, which cannot express this: a
519
+ // grounding-gap refusal and a perfectly good uncertified answer are BOTH
520
+ // `needs_review`, so the guard admitted exactly the failures its comment
521
+ // was written to exclude. Trust is now derived from answer state.
522
+ if (isTrustedConversationTurn(turn))
494
523
  advanceThreadState(store, threadId, turn);
495
524
  }
496
525
  catch {
@@ -529,6 +558,11 @@ export function conversationTurnInputFromRun(run) {
529
558
  trustLabel: agentRunString(payload?.trustLabel) ?? run.trustState,
530
559
  runStatus: run.status,
531
560
  stopReason: run.stopReason,
561
+ // Persisted so a later turn can tell a refusal from an answer. `runStatus`
562
+ // alone cannot: a grounding gap and a good uncertified answer are both
563
+ // `needs_review`.
564
+ refusalCode: agentRunString(payload?.refusalCode),
565
+ executionError: agentRunString(payload?.executionError),
532
566
  certification: agentRunString(payload?.certification),
533
567
  sourceCertifiedBlock: agentRunString(payload?.sourceCertifiedBlock)
534
568
  ?? (artifact?.kind === 'answer' ? agentRunString(artifact.ref) : undefined),
@@ -1542,6 +1576,7 @@ export async function startLocalServer(opts) {
1542
1576
  getSchemaContext: (question, preparedContextPack) => getSchemaContextForAgent(question, preparedContextPack, semanticConnection, request.executionTarget?.target === 'connection'
1543
1577
  ? request.executionTarget.connectionName
1544
1578
  : undefined),
1579
+ probeNamedRelations: (relations) => probeNamedRelationsForAgent(relations, semanticConnection),
1545
1580
  }, (turn) => {
1546
1581
  if (turn.kind === 'thinking')
1547
1582
  onProgress?.(turn.text);
@@ -1927,7 +1962,7 @@ export async function startLocalServer(opts) {
1927
1962
  ],
1928
1963
  };
1929
1964
  }
1930
- const resolvedRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
1965
+ const answeredRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
1931
1966
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
1932
1967
  const isSemantic = governedAnswer.route?.tier === 'semantic_metric';
1933
1968
  const requestedNotebookDataset = findMentionedNotebookDataset(request.question, datasetWorkspace.list());
@@ -1958,6 +1993,12 @@ export async function startLocalServer(opts) {
1958
1993
  // provider outages are blocked so the UI can offer an explicit retry.
1959
1994
  const needsClarification = governedAnswer.kind === 'no_answer'
1960
1995
  && !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
1996
+ // A run that ASKED THE USER A QUESTION is a clarify turn, whatever route it
1997
+ // would have taken had it answered. Persisting the resolved analytical route
1998
+ // instead (`generated_answer`) meant the engine's clarification-continuation
1999
+ // lookup — which matches on `route === 'clarify'` — never found it, so the
2000
+ // reply ran context-free and was clarified again: the reported clarify loop.
2001
+ const resolvedRoute = needsClarification ? 'clarify' : answeredRoute;
1961
2002
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
1962
2003
  const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
1963
2004
  ? undefined
@@ -2002,31 +2043,45 @@ export async function startLocalServer(opts) {
2002
2043
  synthesizedAnswer = undefined;
2003
2044
  }
2004
2045
  }
2046
+ // A grounding/modeling gap is a REFUSAL. It was computed above but never
2047
+ // reached this ternary, so it fell through to `needs_review` — the same
2048
+ // status as a perfectly good uncertified answer. Every downstream trust
2049
+ // check then treated the refusal as a usable turn, which is how one failed
2050
+ // answer poisoned every later question in the thread.
2005
2051
  const status = isTerminalFailure
2006
2052
  ? 'blocked'
2007
2053
  : needsClarification
2008
2054
  ? 'needs_clarification'
2009
- : isCertified || isSemantic
2010
- ? 'completed'
2011
- : 'needs_review';
2055
+ : isGroundingGap || isModelDeclined
2056
+ ? 'blocked'
2057
+ : isCertified || isSemantic
2058
+ ? 'completed'
2059
+ : 'needs_review';
2012
2060
  const trustState = isTerminalFailure
2013
2061
  ? 'blocked'
2014
2062
  : needsClarification
2015
2063
  ? 'not_applicable'
2016
- : isCertified
2017
- ? 'certified'
2018
- : isSemantic
2019
- ? 'governed'
2020
- : 'review_required';
2064
+ : isGroundingGap || isModelDeclined
2065
+ ? 'blocked'
2066
+ : isCertified
2067
+ ? 'certified'
2068
+ : isSemantic
2069
+ ? 'governed'
2070
+ : 'review_required';
2021
2071
  const stopReason = isTerminalFailure
2022
2072
  ? 'blocked'
2023
2073
  : needsClarification
2024
2074
  ? 'needs_clarification'
2025
- : isCertified
2026
- ? 'certified_answer_found'
2027
- : isSemantic
2028
- ? 'governed_semantic_answer'
2029
- : 'human_review_required';
2075
+ // A gap is terminal, but it is NOT a provider outage: it still owes the
2076
+ // user its evidence trace, and its next-actions below stay the
2077
+ // grounding-gap set rather than "retry the provider".
2078
+ : isGroundingGap || isModelDeclined
2079
+ ? 'human_review_required'
2080
+ : isCertified
2081
+ ? 'certified_answer_found'
2082
+ : isSemantic
2083
+ ? 'governed_semantic_answer'
2084
+ : 'human_review_required';
2030
2085
  const terminalFailureActions = governedAnswer.analyticalFailure
2031
2086
  ? governedAnswer.analyticalFailure.code === 'PERMISSION_DENIED'
2032
2087
  ? [{ id: 'repair-access', label: 'Change connection or request access', route: 'blocked' }]
@@ -2172,33 +2227,43 @@ export async function startLocalServer(opts) {
2172
2227
  const kind = routeDecision?.conversationalKind ?? 'smalltalk';
2173
2228
  const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
2174
2229
  const answerKind = isGeneralKnowledge ? 'general_knowledge' : 'conversational';
2175
- const catalogContext = buildAgentRunCatalogContext();
2230
+ const catalogContext = kind === 'answer_explanation' ? '' : buildAgentRunCatalogContext();
2176
2231
  const suggestions = buildConversationSuggestions(projectRoot, kind);
2177
2232
  const nextActions = suggestions.map((prompt, index) => ({
2178
2233
  id: `suggest-question-${index + 1}`,
2179
2234
  label: prompt,
2180
2235
  }));
2181
- let text;
2182
- try {
2183
- const provider = await createBlockStudioAssistProvider(projectRoot);
2184
- if (provider) {
2185
- const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
2186
- const conversationMemory = renderConversationMemoryForPrompt(request.conversationContext);
2187
- const messages = [
2188
- { role: 'system', content: system },
2189
- ...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
2190
- ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
2191
- { role: 'user', content: request.question },
2192
- ];
2193
- text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
2194
- // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
2195
- if (text)
2196
- emitAnswerDelta?.(text);
2197
- }
2236
+ // A question about the latest answer is an evidence lookup, not another
2237
+ // warehouse run. Resolve it from the persisted artifact contract before
2238
+ // considering a provider-generated conversational reply.
2239
+ let text = kind === 'answer_explanation'
2240
+ ? buildPriorAnswerExplanation(request.question, request.conversationContext)
2241
+ : undefined;
2242
+ if (text) {
2243
+ emitAnswerDelta?.(text);
2198
2244
  }
2199
- catch {
2200
- // Provider unavailable / errored — fall through to the deterministic reply.
2201
- text = undefined;
2245
+ else {
2246
+ try {
2247
+ const provider = await createBlockStudioAssistProvider(projectRoot);
2248
+ if (provider) {
2249
+ const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
2250
+ const conversationMemory = renderConversationMemoryForPrompt(request.conversationContext);
2251
+ const messages = [
2252
+ { role: 'system', content: system },
2253
+ ...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
2254
+ ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
2255
+ { role: 'user', content: request.question },
2256
+ ];
2257
+ text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
2258
+ // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
2259
+ if (text)
2260
+ emitAnswerDelta?.(text);
2261
+ }
2262
+ }
2263
+ catch {
2264
+ // Provider unavailable / errored — fall through to the deterministic reply.
2265
+ text = undefined;
2266
+ }
2202
2267
  }
2203
2268
  if (!text) {
2204
2269
  text = buildConversationContextRecap(request.conversationContext)
@@ -2206,7 +2271,7 @@ export async function startLocalServer(opts) {
2206
2271
  emitAnswerDelta?.(text);
2207
2272
  }
2208
2273
  return {
2209
- summary: 'Replied conversationally.',
2274
+ summary: kind === 'answer_explanation' ? 'Explained the previous answer.' : 'Replied conversationally.',
2210
2275
  answer: text,
2211
2276
  answerKind,
2212
2277
  status: 'completed',
@@ -2715,6 +2780,9 @@ export async function startLocalServer(opts) {
2715
2780
  const serverSnapshot = agentRunRecord(request.conversationContext?.conversationEnvelope
2716
2781
  ?? request.conversationContext?.serverSnapshot);
2717
2782
  const topicRelation = agentRunString(serverSnapshot?.topicRelation);
2783
+ const workspaceSurface = agentRunWorkspaceValue(request, 'surface');
2784
+ const blockAuthoringContext = request.requestedMode === 'block'
2785
+ || workspaceSurface === 'block-studio';
2718
2786
  // The readiness marker is source-versioned. When it matches, pass the
2719
2787
  // already-built metadata identity into retrieval so buildLocalContextPack
2720
2788
  // opens the immutable snapshot directly instead of rebuilding all metadata
@@ -2737,6 +2805,8 @@ export async function startLocalServer(opts) {
2737
2805
  const task = buildLocalContextPack(projectRoot, {
2738
2806
  question: request.question,
2739
2807
  focusObjectKey: request.selectedEvidenceId,
2808
+ mode: blockAuthoringContext ? 'build' : 'question',
2809
+ includeDraftBlocks: blockAuthoringContext,
2740
2810
  followUp,
2741
2811
  priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
2742
2812
  conversationTopicRelation: topicRelation === 'continuation'
@@ -2750,7 +2820,9 @@ export async function startLocalServer(opts) {
2750
2820
  reusePolicy: 'seed',
2751
2821
  preparedMetadataFingerprint,
2752
2822
  ...(runtimeSchemaSnapshot ? { runtimeSchemaSnapshot } : {}),
2753
- surface: 'notebook',
2823
+ surface: blockAuthoringContext
2824
+ ? 'block'
2825
+ : workspaceSurface ?? 'notebook',
2754
2826
  selectedContext: {
2755
2827
  selectedObject: request.selectedObject,
2756
2828
  workspaceContext: request.workspaceContext,
@@ -3021,43 +3093,55 @@ export async function startLocalServer(opts) {
3021
3093
  });
3022
3094
  };
3023
3095
  const executeDqlArtifactSourceForAgent = async (source, metadata = {}, invocationInput, executionConnection, executionConnectionName) => {
3024
- const invocation = prepareBlockInvocation({
3096
+ // Everything from here to the executor call is DQL's OWN block compiler:
3097
+ // parse, parameter resolution, plan build, semantic compose. A failure in
3098
+ // any of it means DQL could not rebuild its artifact — the warehouse was
3099
+ // never contacted. Tagging the origin here stops the answer loop from
3100
+ // reporting these as "the generated SQL failed to execute".
3101
+ const invocation = withAnalyticalCompilationOrigin('bind', () => prepareBlockInvocation({
3025
3102
  source,
3026
3103
  parameters: invocationInput?.parameters,
3027
3104
  parameterSources: invocationInput?.parameterSources,
3028
3105
  question: invocationInput?.question,
3029
3106
  surface: 'ask_ai',
3030
- });
3031
- if (invocation.errors.length > 0)
3032
- throw new Error(invocation.errors.join(' '));
3107
+ }));
3108
+ if (invocation.errors.length > 0) {
3109
+ throw analyticalError(invocation.errors.join(' '), { origin: 'dql_compilation', stage: 'compile' });
3110
+ }
3033
3111
  if (invocation.unresolvedParameters.length > 0) {
3034
- throw new Error(`I need values for: ${invocation.unresolvedParameters.join(', ')}.`);
3112
+ throw analyticalError(`I need values for: ${invocation.unresolvedParameters.join(', ')}.`, {
3113
+ origin: 'dql_compilation',
3114
+ stage: 'bind',
3115
+ code: 'unresolved_parameter',
3116
+ offending: { parameter: invocation.unresolvedParameters[0] },
3117
+ });
3035
3118
  }
3036
3119
  const activeConnection = requireActiveConnection(executionConnection);
3037
3120
  const precompiledSemanticQuery = Boolean(extractBlockStudioSql(source));
3038
3121
  const tableMapping = !precompiledSemanticQuery
3039
3122
  ? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot, executionConnectionName)
3040
3123
  : undefined;
3041
- const semanticCompose = semanticLayer
3042
- ? await composeSemanticBlockSqlForRuntime(source, semanticLayer, {
3124
+ const composeLayer = semanticLayer;
3125
+ const semanticCompose = composeLayer
3126
+ ? await withAnalyticalErrorOrigin({ origin: 'dql_compilation', stage: 'compile' }, () => composeSemanticBlockSqlForRuntime(source, composeLayer, {
3043
3127
  driver: activeConnection.driver,
3044
3128
  tableMapping,
3045
3129
  projectRoot,
3046
3130
  projectConfig,
3047
3131
  detectedProvider: semanticDetectedProvider,
3048
3132
  parameters: invocation.values,
3049
- })
3133
+ }))
3050
3134
  : null;
3051
- const plan = buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
3135
+ const plan = withAnalyticalCompilationOrigin('compile', () => buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
3052
3136
  semanticLayer,
3053
3137
  driver: activeConnection.driver,
3054
3138
  tableMapping,
3055
3139
  parameters: invocation.values,
3056
3140
  semanticSql: semanticCompose?.sql ?? undefined,
3057
- });
3141
+ }));
3058
3142
  if (!plan && !semanticCompose?.sql) {
3059
3143
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
3060
- throw new Error(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`);
3144
+ throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
3061
3145
  }
3062
3146
  const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, activeConnection, projectRoot, projectConfig);
3063
3147
  const executionSql = invocationInput?.rowLimit
@@ -3176,9 +3260,164 @@ export async function startLocalServer(opts) {
3176
3260
  },
3177
3261
  };
3178
3262
  };
3263
+ /**
3264
+ * Build the reusable DQL artifact for an answer that HAS ALREADY EXECUTED,
3265
+ * and check that it recompiles.
3266
+ *
3267
+ * AGT-010 requires the attached artifact to be the one used for execution, so
3268
+ * that "Apply" cannot diverge from what Ask displayed. The old code satisfied
3269
+ * that invariant backwards: it made EXECUTION follow the artifact, routing the
3270
+ * model's SQL through `parameterizeSqlForDqlImport` → `candidateToDqlSource` →
3271
+ * the DQL parser → `processSQL` before running anything. Every one of those
3272
+ * stages could fail on SQL the warehouse would have accepted, and each failure
3273
+ * destroyed the whole answer.
3274
+ *
3275
+ * Now the executed string is the source of truth and the artifact is derived
3276
+ * from it. Building the artifact is a SAVE concern, so a failure here degrades
3277
+ * the artifact to review-required and never throws: the user still gets their
3278
+ * answer, and only "Save as block" is affected.
3279
+ */
3280
+ const buildVerifiedGeneratedArtifact = (question, sql, executedSql, seed, warnings, resultColumns = [], dialect = 'duckdb') => {
3281
+ // Which result columns the user may promote to a filter input. Decided here
3282
+ // because it needs a real dialect parse of the SQL that actually ran.
3283
+ const filterableColumns = resultColumns.length > 0
3284
+ ? filterableResultColumns(executedSql, resultColumns, dialect)
3285
+ : [];
3286
+ const withCandidates = (artifact) => (filterableColumns.length > 0 ? { ...artifact, filterableColumns } : artifact);
3287
+ let artifact;
3288
+ try {
3289
+ artifact = generatedSqlArtifactContract(question, sql, seed);
3290
+ }
3291
+ catch (error) {
3292
+ warnings.push(`This answer could not be captured as a reusable DQL block: ${apiErrorMessage(error)}`);
3293
+ return undefined;
3294
+ }
3295
+ try {
3296
+ const invocation = prepareBlockInvocation({ source: artifact.source, question, surface: 'ask_ai' });
3297
+ const divergence = invocation.errors.length > 0
3298
+ ? invocation.errors.join(' ')
3299
+ : invocation.unresolvedParameters.length > 0
3300
+ ? `it still needs values for ${invocation.unresolvedParameters.join(', ')}`
3301
+ : undefined;
3302
+ if (divergence) {
3303
+ warnings.push(`The reusable DQL block for this answer needs review before reuse: ${divergence}`);
3304
+ // Keep the declared parameter contract even when the block cannot run
3305
+ // as-is. This branch is precisely "it still needs values for X", so the
3306
+ // parameter-input surface needs the list in order to ASK for them —
3307
+ // dropping it would leave the user a review-required block with no way
3308
+ // to see, or supply, what it wants.
3309
+ return withCandidates({
3310
+ ...artifact,
3311
+ trustState: 'review_required',
3312
+ compiledSql: executedSql,
3313
+ ...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
3314
+ ...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
3315
+ });
3316
+ }
3317
+ return withCandidates({
3318
+ ...artifact,
3319
+ compiledSql: executedSql,
3320
+ ...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
3321
+ ...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
3322
+ });
3323
+ }
3324
+ catch (error) {
3325
+ warnings.push(`The reusable DQL block for this answer needs review before reuse: ${apiErrorMessage(error)}`);
3326
+ return withCandidates({ ...artifact, trustState: 'review_required', compiledSql: executedSql });
3327
+ }
3328
+ };
3329
+ /**
3330
+ * Execute the model's SQL EXACTLY as validated — the same way a notebook cell
3331
+ * runs it. This is the fix for "the query Ask generated fails in Ask but runs
3332
+ * in the notebook": both paths now reach `executor.executeQuery` with the same
3333
+ * string, so the only remaining reasons to differ are the connection and the
3334
+ * governance gates, both of which report themselves honestly.
3335
+ */
3336
+ const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection) => {
3337
+ const activeConnection = requireActiveConnection(executionConnection);
3338
+ const rowBound = clampAnalyticalRowBound(seed?.limit ?? 200);
3339
+ const trimmed = sql.trim().replace(/;\s*$/, '').trim();
3340
+ if (!trimmed)
3341
+ throw analyticalError('The generated SQL was empty.', { origin: 'host', stage: 'execute' });
3342
+ // RESOLVE FIRST, VALIDATE SECOND. Executing verbatim means executing the
3343
+ // statement the notebook would execute — and the notebook resolves
3344
+ // `@metric()` / `@dim()` refs and dbt macros before it runs anything.
3345
+ // Skipping that here made Ask fail on semantic refs and MetricFlow
3346
+ // (`metric_time`) queries the notebook runs fine, and validating the raw
3347
+ // model SQL first meant `source::` identities and macro-shaped text were
3348
+ // rejected before the step that would have resolved them ever ran.
3349
+ const semanticMapping = semanticLayer && hasStandaloneSemanticRef(trimmed)
3350
+ ? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot)
3351
+ : undefined;
3352
+ const semantic = semanticLayer
3353
+ ? prepareSemanticSql(trimmed, semanticLayer, { tableMapping: semanticMapping })
3354
+ : { sql: trimmed, unresolvedRefs: [], semanticRefs: undefined };
3355
+ if (semantic.unresolvedRefs.length > 0) {
3356
+ throw analyticalError(`Unknown semantic reference${semantic.unresolvedRefs.length > 1 ? 's' : ''}: ${semantic.unresolvedRefs.join(', ')}`, { origin: 'dql_compilation', stage: 'compile', code: 'semantic_ref' });
3357
+ }
3358
+ const prepared = prepareLocalExecution(semantic.sql, activeConnection, projectRoot, projectConfig);
3359
+ // Read-only enforcement runs on the statement that will ACTUALLY execute.
3360
+ const readOnlyError = readOnlySqlValidationError(prepared.sql, 'Generated SQL', activeConnection.driver);
3361
+ if (readOnlyError) {
3362
+ throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
3363
+ }
3364
+ const bounded = buildRowBoundedSql(prepared.sql, rowBound, activeConnection.driver);
3365
+ const app = loadRuntimeApp(projectRoot, activePersonaAppId());
3366
+ // Generated SQL is governed as 'uncategorized' unless its seed declares a
3367
+ // domain — the same value `generatedSqlArtifactContract` stamps into the
3368
+ // block source. Falling through to `app.domain` instead would evaluate the
3369
+ // policy against the app's OWN domain and could allow a query the previous
3370
+ // release denied; a governance boundary must not move as a side effect.
3371
+ const sourceDomain = seed?.source?.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1] ?? 'uncategorized';
3372
+ assertAppAccess({ app, domain: sourceDomain, level: 'execute' });
3373
+ // A semantic query the loop already compiled (MetricFlow / dbt Cloud) must
3374
+ // execute through its pinned target binding, not as loose SQL.
3375
+ const pinnedSemanticCompile = compiledSemanticQueries.get(executionFingerprint(prepared.sql))
3376
+ ?? compiledSemanticQueries.get(executionFingerprint(trimmed));
3377
+ const semanticExecution = pinnedSemanticCompile
3378
+ ? await executeTargetBoundSemanticQuery({
3379
+ executor,
3380
+ connection: activeConnection,
3381
+ projectRoot,
3382
+ plannedAdapter: pinnedSemanticCompile.engine,
3383
+ metricFlow: pinnedSemanticCompile.engine === 'metricflow-cli'
3384
+ ? resolveMetricFlowTargetMetadata(projectRoot, projectConfig)
3385
+ : undefined,
3386
+ compile: async () => pinnedSemanticCompile,
3387
+ prepareSql: () => ({ sql: bounded.sql, connection: prepared.connection }),
3388
+ rowBound,
3389
+ })
3390
+ : null;
3391
+ const rawResult = semanticExecution?.result
3392
+ ?? await executor.executeQuery(bounded.sql, [], runtimeVariables({}), prepared.connection);
3393
+ const normalized = normalizeQueryResult(rawResult, semantic.semanticRefs);
3394
+ // The authoritative bound. `buildRowBoundedSql` may legitimately decline to
3395
+ // touch the statement, and only one of 15 drivers honours a maxRows hint, so
3396
+ // the host is the only layer correctness may depend on.
3397
+ const truncated = normalized.rows.length > rowBound;
3398
+ const rows = truncated ? normalized.rows.slice(0, rowBound) : normalized.rows;
3399
+ const warnings = [];
3400
+ const artifact = buildVerifiedGeneratedArtifact(question, trimmed, bounded.sql, seed, warnings, normalized.columns, activeConnection.driver);
3401
+ return {
3402
+ columns: normalized.columns,
3403
+ rows,
3404
+ rowCount: rows.length,
3405
+ executionTime: normalized.executionTime,
3406
+ sql: bounded.sql,
3407
+ ...(truncated ? { truncated: true } : {}),
3408
+ ...(artifact ? { dqlArtifact: { ...artifact, limit: rowBound } } : {}),
3409
+ ...(warnings.length > 0 ? { validationWarnings: warnings } : {}),
3410
+ executionReceipt: createDqlArtifactExecutionReceipt(artifact?.source ?? trimmed, bounded.sql, {}, { columns: normalized.columns, rows, rowCount: rows.length }),
3411
+ };
3412
+ };
3179
3413
  const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName) => {
3180
- const artifact = generatedSqlArtifactContract(question, sql, seed);
3181
- return executeArtifactReferenceForAgent({ ...artifact, limit: seed?.limit ?? artifact.limit ?? 200 }, question, executionConnection, executionConnectionName);
3414
+ // A seed that is already a certified/saved artifact keeps the DQL-first
3415
+ // path: there the `.dql` source IS the contract, and its parameters and
3416
+ // semantic refs must be compiled, not bypassed.
3417
+ if (seed && seed.kind !== 'sql_block') {
3418
+ return executeArtifactReferenceForAgent({ ...seed, limit: seed.limit ?? 200 }, question, executionConnection, executionConnectionName);
3419
+ }
3420
+ return executeGeneratedSqlDirect(question, sql, seed, executionConnection);
3182
3421
  };
3183
3422
  /**
3184
3423
  * EXP-001: execution host for the deliberately narrow non-governed lane.
@@ -3287,6 +3526,60 @@ export async function startLocalServer(opts) {
3287
3526
  return { proofs, sql: boundedSql, repairs, error: error instanceof Error ? error.message : String(error) };
3288
3527
  }
3289
3528
  };
3529
+ /**
3530
+ * Ask the warehouse whether specific named relations exist, AFTER the cached
3531
+ * catalog has already missed them.
3532
+ *
3533
+ * `getSchemaContextForAgent` deliberately returns nothing on a lexical miss —
3534
+ * "a lexical miss is not permission to rescan every visible schema". That is
3535
+ * right for BULK retrieval, but it also meant a relation the model named
3536
+ * explicitly, and that the notebook queries without complaint, was refused as
3537
+ * "outside the inspected metadata context" without the warehouse ever being
3538
+ * asked. A point lookup of up to four named `(schema, table)` pairs is not a
3539
+ * rescan.
3540
+ *
3541
+ * The payoff is honesty as much as recall: after this probe, a miss is
3542
+ * evidence. The refusal can say the relation is not visible to this
3543
+ * connection, rather than only that DQL had not looked at it.
3544
+ */
3545
+ const probeNamedRelationsForAgent = async (relations, probeConnection) => {
3546
+ const probeTarget = probeConnection ?? connection;
3547
+ if (!probeTarget)
3548
+ return undefined;
3549
+ // Same operator switch that governs live value grounding: a deployment that
3550
+ // wants zero live metadata traffic keeps it.
3551
+ if (resolveAgentRuntimeValueGrounding(projectConfig).mode !== 'safe_automatic')
3552
+ return undefined;
3553
+ const sql = buildNamedRelationProbeSql(relations);
3554
+ if (!sql)
3555
+ return undefined;
3556
+ const prepared = prepareLocalExecution(sql, probeTarget, projectRoot, projectConfig);
3557
+ const result = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
3558
+ const rows = normalizeQueryResult(result).rows;
3559
+ if (rows.length === 0)
3560
+ return undefined;
3561
+ const tables = buildAgentSchemaContext('', rows, { includeUnscored: true, limit: 8 });
3562
+ if (tables.length === 0)
3563
+ return undefined;
3564
+ return {
3565
+ relations: tables.map((table) => ({
3566
+ relation: table.relation,
3567
+ name: table.name,
3568
+ // Provenance is explicit: these columns came from a live lookup, not
3569
+ // from the immutable snapshot.
3570
+ source: 'scoped live probe',
3571
+ columns: table.columns.map((column) => ({ name: column.name, type: column.type })),
3572
+ columnCompleteness: 'complete',
3573
+ })),
3574
+ schemaContext: tables.map((table) => ({
3575
+ relation: table.relation,
3576
+ schema: table.schema,
3577
+ name: table.name,
3578
+ columns: table.columns,
3579
+ })),
3580
+ notes: tables.map((table) => `${table.relation} resolved by a scoped live probe`),
3581
+ };
3582
+ };
3290
3583
  const getSchemaContextForAgent = async (question, preparedContextPack, executionConnection, executionConnectionName) => {
3291
3584
  const schemaConnection = executionConnection ?? connection;
3292
3585
  const scanRuntimeSchema = async () => {
@@ -12135,6 +12428,60 @@ export async function startLocalServer(opts) {
12135
12428
  res.end(readFileSync(filePath));
12136
12429
  return;
12137
12430
  }
12431
+ /**
12432
+ * Promote a RESULT COLUMN into a runtime filter input on a generated answer.
12433
+ *
12434
+ * The SQL edit happens here, not in the browser: deciding where a predicate
12435
+ * may go needs a real dialect parse, and shipping a SQL parser to the client
12436
+ * to guess at it is exactly how a filter ends up silently attached to the
12437
+ * wrong query. The client sends a column; it gets back an artifact.
12438
+ */
12439
+ if (req.method === 'POST' && path === '/api/dql/artifacts/add-filter') {
12440
+ try {
12441
+ const body = await readJSON(req);
12442
+ const artifact = normalizeDqlArtifactReference(body.artifact);
12443
+ const column = typeof body.column === 'string' ? body.column.trim() : '';
12444
+ const value = body.value;
12445
+ const resultColumns = Array.isArray(body.resultColumns)
12446
+ ? body.resultColumns.filter((entry) => typeof entry === 'string')
12447
+ : [];
12448
+ if (!artifact)
12449
+ throw new Error('A DQL artifact is required to add a filter input.');
12450
+ if (!column)
12451
+ throw new Error('Choose a result column to filter on.');
12452
+ if (value === undefined || value === null || String(value).trim() === '') {
12453
+ throw new Error('Choose a default filter value before adding the input.');
12454
+ }
12455
+ const sql = extractBlockStudioSql(artifact.source);
12456
+ if (!sql)
12457
+ throw new Error('This answer has no editable SQL to filter.');
12458
+ const activeConnection = requireActiveConnection();
12459
+ const existingNames = (artifact.parameters ?? []).map((parameter) => parameter.name);
12460
+ const added = addSqlResultFilter(sql, resultColumns, column, existingNames, activeConnection.driver);
12461
+ const source = replaceBlockStudioSql(artifact.source, added.sql, added.parameterName, value);
12462
+ // Re-parse so the returned contract is what the block ACTUALLY declares,
12463
+ // rather than what this endpoint believes it wrote.
12464
+ const invocation = prepareBlockInvocation({ source, surface: 'ask_ai' });
12465
+ if (invocation.errors.length > 0)
12466
+ throw new Error(invocation.errors.join(' '));
12467
+ const { executionReceipt: _receipt, compiledSql: _compiled, ...unexecuted } = artifact;
12468
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
12469
+ res.end(serializeJSON({
12470
+ artifact: {
12471
+ ...unexecuted,
12472
+ source,
12473
+ parameters: invocation.parameters,
12474
+ parameterValues: { ...(artifact.parameterValues ?? {}), [added.parameterName]: value },
12475
+ },
12476
+ parameterName: added.parameterName,
12477
+ }));
12478
+ }
12479
+ catch (error) {
12480
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
12481
+ res.end(serializeJSON({ error: apiErrorMessage(error) }));
12482
+ }
12483
+ return;
12484
+ }
12138
12485
  if (req.method === 'POST' && path === '/api/dql/artifacts/execute') {
12139
12486
  try {
12140
12487
  const body = await readJSON(req);
@@ -15093,18 +15440,80 @@ const AGENT_PREVIEW_FORBIDDEN_SQL = [
15093
15440
  'update',
15094
15441
  'vacuum',
15095
15442
  ];
15443
+ /**
15444
+ * Run DQL's own block-compiler machinery with an honest origin. Anything these
15445
+ * stages throw is a DQL-artifact failure, never a warehouse failure.
15446
+ */
15447
+ function withAnalyticalCompilationOrigin(stage, fn) {
15448
+ return withAnalyticalErrorOriginSync({ origin: 'dql_compilation', stage }, fn);
15449
+ }
15450
+ export function clampAnalyticalRowBound(rowLimit) {
15451
+ return Number.isFinite(rowLimit) ? Math.max(1, Math.min(10_000, Math.floor(rowLimit))) : 200;
15452
+ }
15453
+ /**
15454
+ * Dialects where a bare trailing `LIMIT n` is valid on a top-level SELECT.
15455
+ * MSSQL/Fabric need `TOP`/`OFFSET…FETCH` in positions that depend on the rest
15456
+ * of the statement, so we never rewrite there — the host-side truncation below
15457
+ * is the guarantee, and appending is only an optimization.
15458
+ */
15459
+ const TRAILING_LIMIT_DIALECTS = new Set([
15460
+ 'duckdb', 'postgres', 'postgresql', 'redshift', 'snowflake', 'bigquery',
15461
+ 'databricks', 'trino', 'presto', 'clickhouse', 'mysql', 'mariadb', 'sqlite', 'athena',
15462
+ ]);
15463
+ /**
15464
+ * Bound a result set WITHOUT rewriting the statement's shape.
15465
+ *
15466
+ * The previous approach wrapped every Ask query as
15467
+ * `SELECT * FROM (<sql>) AS dql_agent_preview LIMIT n`. That is not a no-op:
15468
+ * - `SELECT * FROM (WITH x AS (…) SELECT …) AS t` is a syntax error on
15469
+ * MSSQL/Fabric, and the model emits CTEs constantly;
15470
+ * - duplicate output column names are legal at top level but not as a
15471
+ * derived table on several engines;
15472
+ * - the inner `ORDER BY` stops being guaranteed, silently breaking top-N.
15473
+ * All three produced "fails in Ask, runs in the notebook" for identical SQL.
15474
+ *
15475
+ * Appending is skipped whenever it cannot be proven safe. Callers must still
15476
+ * truncate rows host-side — that, not this, is what actually enforces the bound.
15477
+ */
15478
+ export function buildRowBoundedSql(sql, rowLimit, dialect = 'duckdb') {
15479
+ const trimmed = sql.trim().replace(/;\s*$/, '').trim();
15480
+ if (!rowLimit)
15481
+ return { sql: trimmed, outcome: 'skipped', reason: 'no row bound requested' };
15482
+ if (!TRAILING_LIMIT_DIALECTS.has(dialect.toLowerCase())) {
15483
+ return { sql: trimmed, outcome: 'skipped', reason: `trailing LIMIT is not portable on ${dialect}` };
15484
+ }
15485
+ const analysis = analyzeSqlReferences(trimmed, dialect);
15486
+ if (!analysis.parsed) {
15487
+ return { sql: trimmed, outcome: 'skipped', reason: 'statement did not parse; not rewriting it' };
15488
+ }
15489
+ const statements = analysis.statementTypes ?? [];
15490
+ if (statements.length !== 1 || statements[0]?.toLowerCase() !== 'select') {
15491
+ return { sql: trimmed, outcome: 'skipped', reason: 'not a single SELECT statement' };
15492
+ }
15493
+ // Any existing bound — LIMIT, FETCH FIRST/NEXT, TOP — wins. Detection is
15494
+ // deliberately greedy: a false positive costs nothing (host truncation still
15495
+ // applies), a false negative would override the author's own bound.
15496
+ const scan = stripSqlStringsAndComments(trimmed);
15497
+ if (/\blimit\b/i.test(scan) || /\bfetch\s+(first|next)\b/i.test(scan) || /\btop\s*\(?\s*\d/i.test(scan)) {
15498
+ return { sql: trimmed, outcome: 'existing', reason: 'statement already carries its own bound' };
15499
+ }
15500
+ return { sql: `${trimmed}\nLIMIT ${clampAnalyticalRowBound(rowLimit)}`, outcome: 'appended' };
15501
+ }
15502
+ /**
15503
+ * @deprecated Kept for one release so any out-of-tree caller keeps working.
15504
+ * Use {@link executeAnalyticalSql}, which bounds rows without wrapping.
15505
+ */
15096
15506
  export function buildAgentPreviewSql(sql, rowLimit = 200) {
15097
15507
  const trimmed = sql.trim();
15098
15508
  if (!trimmed)
15099
- throw new Error('Generated SQL preview is empty.');
15100
- const boundedRowLimit = Number.isFinite(rowLimit)
15101
- ? Math.max(1, Math.min(10_000, Math.floor(rowLimit)))
15102
- : 200;
15509
+ throw analyticalError('Generated SQL preview is empty.', { origin: 'host', stage: 'execute' });
15103
15510
  const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, '').trim();
15104
15511
  const readOnlyError = readOnlySqlValidationError(withoutTrailingSemicolon, 'Generated SQL preview');
15105
- if (readOnlyError)
15106
- throw new Error(readOnlyError);
15107
- return `SELECT * FROM (\n${withoutTrailingSemicolon}\n) AS dql_agent_preview LIMIT ${boundedRowLimit}`;
15512
+ // A deliberate read-only refusal, not a warehouse rejection.
15513
+ if (readOnlyError) {
15514
+ throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
15515
+ }
15516
+ return buildRowBoundedSql(withoutTrailingSemicolon, rowLimit).sql;
15108
15517
  }
15109
15518
  /**
15110
15519
  * EXP-001 preflight for model-authored exploratory SQL.
@@ -15492,7 +15901,7 @@ function summarizeExploratoryJoinProbe(rows, leftRelation, leftColumn, rightRela
15492
15901
  };
15493
15902
  return `${leftRelation}.${leftColumn} ↔ ${rightRelation}.${rightColumn}: sample left=${number('left_sample_rows')}, right=${number('right_sample_rows')}, joined=${number('joined_rows')}, unmatched left/right=${number('unmatched_left_sample_rows')}/${number('unmatched_right_sample_rows')}, max matches left/right=${number('max_matches_per_left_key')}/${number('max_matches_per_right_key')}`;
15494
15903
  }
15495
- function readOnlySqlValidationError(sql, subject) {
15904
+ function readOnlySqlValidationError(sql, subject, dialect = 'duckdb') {
15496
15905
  const internalRelationError = internalDqlRelationIdValidationError(sql, subject);
15497
15906
  if (internalRelationError)
15498
15907
  return internalRelationError;
@@ -15503,6 +15912,22 @@ function readOnlySqlValidationError(sql, subject) {
15503
15912
  if (scanSql.includes(';')) {
15504
15913
  return `${subject} only supports one statement.`;
15505
15914
  }
15915
+ // Prefer the parser. The keyword blacklist matches IDENTIFIERS, so a CTE or
15916
+ // column alias legitimately named `set`, `load`, `merge`, or `copy` was
15917
+ // rejected as if it were DDL — a false failure on SQL the notebook runs fine.
15918
+ // The blacklist stays as the conservative fallback for statements the dialect
15919
+ // parser cannot read.
15920
+ const analysis = analyzeSqlReferences(scanSql, dialect);
15921
+ if (analysis.parsed) {
15922
+ const statements = analysis.statementTypes ?? [];
15923
+ const offending = statements.find((statement) => statement.toLowerCase() !== 'select');
15924
+ if (offending) {
15925
+ return `${subject} rejected unsupported statement keyword: ${offending.toUpperCase()}.`;
15926
+ }
15927
+ if (statements.length > 1)
15928
+ return `${subject} only supports one statement.`;
15929
+ return null;
15930
+ }
15506
15931
  const forbiddenPattern = new RegExp(`\\b(${AGENT_PREVIEW_FORBIDDEN_SQL.join('|')})\\b`, 'i');
15507
15932
  const forbidden = scanSql.match(forbiddenPattern)?.[1];
15508
15933
  if (forbidden) {
@@ -18804,7 +19229,7 @@ function agentResultToSynthesisPreview(result) {
18804
19229
  }
18805
19230
  /** Up to three example questions built from real catalog blocks (falls back to generic asks). */
18806
19231
  function buildConversationSuggestions(projectRoot, kind) {
18807
- if (kind === 'gratitude')
19232
+ if (kind === 'gratitude' || kind === 'answer_explanation')
18808
19233
  return [];
18809
19234
  let names = [];
18810
19235
  try {
@@ -18900,9 +19325,9 @@ export function buildConversationContextRecap(context) {
18900
19325
  return undefined;
18901
19326
  return `We were talking about ${contextBits.join('. ')}.`;
18902
19327
  }
18903
- function buildConversationTurnsRecap(context) {
19328
+ function latestSubstantiveConversationTurn(context) {
18904
19329
  const turns = Array.isArray(context.turns)
18905
- ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-3)
19330
+ ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-6)
18906
19331
  : [];
18907
19332
  if (turns.length === 0)
18908
19333
  return undefined;
@@ -18914,9 +19339,120 @@ function buildConversationTurnsRecap(context) {
18914
19339
  // subject of the next recap. Prefer the newest analytical/authoring turn and
18915
19340
  // fall back to the active turn only when the thread contains conversation alone.
18916
19341
  const substantiveTurns = turns.filter((turn) => agentRunString(turn.route) !== 'conversation');
18917
- const selected = active && agentRunString(active.route) !== 'conversation'
19342
+ return active && agentRunString(active.route) !== 'conversation'
18918
19343
  ? active
18919
19344
  : substantiveTurns[substantiveTurns.length - 1] ?? active ?? turns[turns.length - 1];
19345
+ }
19346
+ /**
19347
+ * Explain the latest successful answer from its persisted artifact contract.
19348
+ * This is intentionally deterministic and never executes SQL: missing evidence
19349
+ * is reported as missing instead of being guessed or converted into a new metric.
19350
+ */
19351
+ export function buildPriorAnswerExplanation(question, context) {
19352
+ if (!context)
19353
+ return undefined;
19354
+ const selected = latestSubstantiveConversationTurn(context);
19355
+ const artifact = agentRunRecord(selected?.dqlArtifact) ?? agentRunRecord(context.dqlArtifact);
19356
+ const result = agentRunRecord(selected?.result);
19357
+ const metrics = agentRunStringArray(artifact?.metrics
19358
+ ?? selected?.requestedMeasures
19359
+ ?? context.priorMeasures);
19360
+ const dimensions = agentRunStringArray(artifact?.dimensions
19361
+ ?? selected?.requestedDimensions
19362
+ ?? context.requestedDimensions);
19363
+ const timeDimension = agentRunRecord(artifact?.timeDimension);
19364
+ const timeName = agentRunString(timeDimension?.name);
19365
+ const granularity = agentRunString(timeDimension?.granularity);
19366
+ const filters = formatPriorAnswerFilters(artifact?.filters, selected?.requestedFilters ?? context.requestedFilters);
19367
+ const topN = typeof selected?.topN === 'number'
19368
+ ? selected.topN
19369
+ : typeof context.priorLimit === 'number'
19370
+ ? context.priorLimit
19371
+ : undefined;
19372
+ const source = agentRunString(artifact?.name)
19373
+ ?? agentRunString(selected?.sourceCertifiedBlock)
19374
+ ?? agentRunString(context.sourceCertifiedBlock);
19375
+ const columns = agentRunStringArray(result?.columns ?? context.resultColumns).slice(0, 8);
19376
+ const metricText = metrics.length > 0 ? metrics.map(humanizeArtifactField).join(', ') : undefined;
19377
+ const dimensionText = dimensions.length > 0 ? dimensions.map(humanizeArtifactField).join(', ') : undefined;
19378
+ const normalizedQuestion = question.toLowerCase();
19379
+ if (/\b(?:daily|monthly|weekly|quarterly|yearly|annual|grain|period|timeframe|date range)\b/.test(normalizedQuestion)) {
19380
+ if (granularity) {
19381
+ return [
19382
+ `The previous result uses${metricText ? ` ${metricText}` : ' the recorded metric'} and is grouped`
19383
+ + `${timeName ? ` by ${humanizeArtifactField(timeName)}` : ''} at a ${formatTimeGranularity(granularity)} grain.`,
19384
+ filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
19385
+ ].filter(Boolean).join(' ');
19386
+ }
19387
+ return [
19388
+ `The previous result does not declare a daily or monthly time grain${metricText ? ` for ${metricText}` : ''}.`,
19389
+ dimensionText ? `Its recorded grouping is ${dimensionText}.` : 'It is an aggregate across the applied result scope.',
19390
+ filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
19391
+ 'If you want a new monthly or daily breakdown, ask me to show or group it that way.',
19392
+ ].filter(Boolean).join(' ');
19393
+ }
19394
+ if (/\b(?:which|what)\s+(?:metric|measure)\b|\bmetric\s+(?:did|does|was|is)\b/.test(normalizedQuestion)) {
19395
+ return metricText
19396
+ ? `The previous answer used ${metricText}${source ? ` from ${humanizeArtifactField(source)}` : ''}.`
19397
+ : 'The previous answer did not retain a qualified metric identifier, so I cannot name one without rerunning or reviewing its DQL.';
19398
+ }
19399
+ if (/\bfilter|include|exclude\b/.test(normalizedQuestion)) {
19400
+ return filters.length > 0
19401
+ ? `The previous answer applied these filters: ${filters.join('; ')}.`
19402
+ : 'The previous artifact does not record any explicit filters.';
19403
+ }
19404
+ const facts = [
19405
+ metricText ? `Metric: ${metricText}.` : '',
19406
+ dimensionText ? `Grouped by: ${dimensionText}.` : '',
19407
+ granularity ? `Time grain: ${formatTimeGranularity(granularity)}${timeName ? ` on ${humanizeArtifactField(timeName)}` : ''}.` : '',
19408
+ filters.length > 0 ? `Filters: ${filters.join('; ')}.` : '',
19409
+ topN ? `Limit: top ${topN}.` : '',
19410
+ source ? `Source: ${humanizeArtifactField(source)}.` : '',
19411
+ columns.length > 0 ? `Returned fields: ${columns.map(humanizeArtifactField).join(', ')}.` : '',
19412
+ ].filter(Boolean);
19413
+ return facts.length > 0
19414
+ ? facts.join(' ')
19415
+ : 'The previous answer did not retain enough artifact metadata to explain its metric, filters, or grain safely.';
19416
+ }
19417
+ function formatPriorAnswerFilters(primary, fallback) {
19418
+ const fromArtifact = Array.isArray(primary)
19419
+ ? primary.map(agentRunRecord).filter((filter) => Boolean(filter)).flatMap((filter) => {
19420
+ const dimension = agentRunString(filter.dimension);
19421
+ const operator = agentRunString(filter.operator) ?? 'equals';
19422
+ const values = agentRunStringArray(filter.values);
19423
+ return dimension && values.length > 0
19424
+ ? [`${humanizeArtifactField(dimension)} ${operator.replace(/_/g, ' ')} ${values.join(', ')}`]
19425
+ : [];
19426
+ })
19427
+ : [];
19428
+ return fromArtifact.length > 0
19429
+ ? fromArtifact
19430
+ : agentRunStringArray(fallback).map((value) => value.replace(/_/g, ' '));
19431
+ }
19432
+ function humanizeArtifactField(value) {
19433
+ return value
19434
+ .split('.')
19435
+ .at(-1)
19436
+ .replace(/::/g, ' ')
19437
+ .replace(/[_-]+/g, ' ')
19438
+ .replace(/\s+/g, ' ')
19439
+ .trim();
19440
+ }
19441
+ function formatTimeGranularity(value) {
19442
+ const normalized = value.trim().toLowerCase();
19443
+ return {
19444
+ day: 'daily',
19445
+ week: 'weekly',
19446
+ month: 'monthly',
19447
+ quarter: 'quarterly',
19448
+ year: 'yearly',
19449
+ }[normalized] ?? humanizeArtifactField(value);
19450
+ }
19451
+ function buildConversationTurnsRecap(context) {
19452
+ const turns = Array.isArray(context.turns)
19453
+ ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-3)
19454
+ : [];
19455
+ const selected = latestSubstantiveConversationTurn(context);
18920
19456
  if (!selected)
18921
19457
  return undefined;
18922
19458
  const result = agentRunRecord(selected.result);
@@ -22423,6 +22959,51 @@ const RUNTIME_SCHEMA_SEARCH_STOPWORDS = new Set([
22423
22959
  * execution target. Tokens are reduced to safe identifier characters before
22424
22960
  * interpolation, so user text cannot become SQL syntax.
22425
22961
  */
22962
+ /**
22963
+ * Point-lookup of NAMED relations in `information_schema.columns`.
22964
+ *
22965
+ * This is deliberately not `buildRuntimeSchemaSearchSql`. That one matches
22966
+ * `LOWER(table_name) LIKE '%term%'` across every visible schema, which is the
22967
+ * unbounded rescan the "a lexical miss is not permission to rescan" policy
22968
+ * exists to prevent. This one asks about specific `(schema, table)` pairs the
22969
+ * model actually referenced, with equality predicates — a metadata point
22970
+ * lookup, not a scan.
22971
+ *
22972
+ * Returns null when nothing safe to ask about survives normalization.
22973
+ */
22974
+ export function buildNamedRelationProbeSql(relations, maxRelations = 4) {
22975
+ const predicates = [];
22976
+ const seen = new Set();
22977
+ for (const raw of relations) {
22978
+ const parts = raw.replace(/["`\[\]]/g, '').trim().split('.').filter(Boolean);
22979
+ const table = parts.at(-1);
22980
+ // Identifiers only — never interpolate anything that could carry SQL.
22981
+ if (!table || !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(table))
22982
+ continue;
22983
+ const schema = parts.length >= 2 ? parts.at(-2) : undefined;
22984
+ if (schema && !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema))
22985
+ continue;
22986
+ const key = `${schema ?? ''}.${table}`.toLowerCase();
22987
+ if (seen.has(key))
22988
+ continue;
22989
+ seen.add(key);
22990
+ predicates.push(schema
22991
+ ? `(LOWER(table_schema) = '${schema.toLowerCase()}' AND LOWER(table_name) = '${table.toLowerCase()}')`
22992
+ : `(LOWER(table_name) = '${table.toLowerCase()}')`);
22993
+ if (predicates.length >= maxRelations)
22994
+ break;
22995
+ }
22996
+ if (predicates.length === 0)
22997
+ return null;
22998
+ return `SELECT table_schema, table_name, column_name, data_type
22999
+ FROM information_schema.columns
23000
+ WHERE UPPER(table_schema) NOT IN ('INFORMATION_SCHEMA', 'PG_CATALOG')
23001
+ AND (
23002
+ ${predicates.map((predicate) => ` ${predicate}`).join(' OR\n')}
23003
+ )
23004
+ ORDER BY table_schema, table_name, ordinal_position
23005
+ LIMIT 400`;
23006
+ }
22426
23007
  export function buildRuntimeSchemaSearchSql(question) {
22427
23008
  const terms = Array.from(agentSchemaTokens(question))
22428
23009
  .map((term) => term.toLowerCase().replace(/[^a-z0-9_]/g, ''))