@duckcodeailabs/dql-agent 1.13.5 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/dist/agent-run-engine.d.ts +79 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +457 -39
  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 +13 -0
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-store.d.ts.map +1 -1
  9. package/dist/agent-run-store.js +42 -25
  10. package/dist/agent-run-store.js.map +1 -1
  11. package/dist/agentic/analyst-loop.d.ts +91 -0
  12. package/dist/agentic/analyst-loop.d.ts.map +1 -0
  13. package/dist/agentic/analyst-loop.js +311 -0
  14. package/dist/agentic/analyst-loop.js.map +1 -0
  15. package/dist/agentic/assumptions.d.ts +62 -0
  16. package/dist/agentic/assumptions.d.ts.map +1 -0
  17. package/dist/agentic/assumptions.js +73 -0
  18. package/dist/agentic/assumptions.js.map +1 -0
  19. package/dist/agentic/business-explanation.d.ts +56 -0
  20. package/dist/agentic/business-explanation.d.ts.map +1 -0
  21. package/dist/agentic/business-explanation.js +130 -0
  22. package/dist/agentic/business-explanation.js.map +1 -0
  23. package/dist/agentic/identifier-ledger.d.ts +74 -0
  24. package/dist/agentic/identifier-ledger.d.ts.map +1 -0
  25. package/dist/agentic/identifier-ledger.js +151 -0
  26. package/dist/agentic/identifier-ledger.js.map +1 -0
  27. package/dist/agentic/ledger-tools.d.ts +68 -0
  28. package/dist/agentic/ledger-tools.d.ts.map +1 -0
  29. package/dist/agentic/ledger-tools.js +198 -0
  30. package/dist/agentic/ledger-tools.js.map +1 -0
  31. package/dist/agentic/orchestrator-policy.d.ts +60 -0
  32. package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
  33. package/dist/agentic/orchestrator-policy.js +73 -0
  34. package/dist/agentic/orchestrator-policy.js.map +1 -0
  35. package/dist/agentic/orchestrator.d.ts +54 -0
  36. package/dist/agentic/orchestrator.d.ts.map +1 -0
  37. package/dist/agentic/orchestrator.js +67 -0
  38. package/dist/agentic/orchestrator.js.map +1 -0
  39. package/dist/agentic/preview-tool.d.ts +36 -0
  40. package/dist/agentic/preview-tool.d.ts.map +1 -0
  41. package/dist/agentic/preview-tool.js +71 -0
  42. package/dist/agentic/preview-tool.js.map +1 -0
  43. package/dist/agentic/rerank.d.ts +50 -0
  44. package/dist/agentic/rerank.d.ts.map +1 -0
  45. package/dist/agentic/rerank.js +140 -0
  46. package/dist/agentic/rerank.js.map +1 -0
  47. package/dist/agentic/research/hypothesis.d.ts +110 -0
  48. package/dist/agentic/research/hypothesis.d.ts.map +1 -0
  49. package/dist/agentic/research/hypothesis.js +125 -0
  50. package/dist/agentic/research/hypothesis.js.map +1 -0
  51. package/dist/agentic/research/synthesis.d.ts +32 -0
  52. package/dist/agentic/research/synthesis.d.ts.map +1 -0
  53. package/dist/agentic/research/synthesis.js +94 -0
  54. package/dist/agentic/research/synthesis.js.map +1 -0
  55. package/dist/agentic/research-agent.d.ts +54 -0
  56. package/dist/agentic/research-agent.d.ts.map +1 -0
  57. package/dist/agentic/research-agent.js +176 -0
  58. package/dist/agentic/research-agent.js.map +1 -0
  59. package/dist/agentic/sql-authorization.d.ts +131 -0
  60. package/dist/agentic/sql-authorization.d.ts.map +1 -0
  61. package/dist/agentic/sql-authorization.js +232 -0
  62. package/dist/agentic/sql-authorization.js.map +1 -0
  63. package/dist/agentic/tool-loop.d.ts +29 -0
  64. package/dist/agentic/tool-loop.d.ts.map +1 -1
  65. package/dist/agentic/tool-loop.js +129 -52
  66. package/dist/agentic/tool-loop.js.map +1 -1
  67. package/dist/agentic/toolset.d.ts.map +1 -1
  68. package/dist/agentic/toolset.js +155 -0
  69. package/dist/agentic/toolset.js.map +1 -1
  70. package/dist/agentic/turn-plan.d.ts +35 -0
  71. package/dist/agentic/turn-plan.d.ts.map +1 -0
  72. package/dist/agentic/turn-plan.js +108 -0
  73. package/dist/agentic/turn-plan.js.map +1 -0
  74. package/dist/agentic/value-probe.d.ts +89 -0
  75. package/dist/agentic/value-probe.d.ts.map +1 -0
  76. package/dist/agentic/value-probe.js +304 -0
  77. package/dist/agentic/value-probe.js.map +1 -0
  78. package/dist/analytical-orchestration.d.ts +336 -0
  79. package/dist/analytical-orchestration.d.ts.map +1 -0
  80. package/dist/analytical-orchestration.js +631 -0
  81. package/dist/analytical-orchestration.js.map +1 -0
  82. package/dist/analytical-request-policy.d.ts +26 -0
  83. package/dist/analytical-request-policy.d.ts.map +1 -0
  84. package/dist/analytical-request-policy.js +82 -0
  85. package/dist/analytical-request-policy.js.map +1 -0
  86. package/dist/analytical-result-facts.d.ts +14 -1
  87. package/dist/analytical-result-facts.d.ts.map +1 -1
  88. package/dist/analytical-result-facts.js +76 -1
  89. package/dist/analytical-result-facts.js.map +1 -1
  90. package/dist/answer-loop.d.ts +61 -16
  91. package/dist/answer-loop.d.ts.map +1 -1
  92. package/dist/answer-loop.js +256 -36
  93. package/dist/answer-loop.js.map +1 -1
  94. package/dist/cascade/budgets.d.ts.map +1 -1
  95. package/dist/cascade/budgets.js +8 -1
  96. package/dist/cascade/budgets.js.map +1 -1
  97. package/dist/cascade/packer.d.ts +88 -0
  98. package/dist/cascade/packer.d.ts.map +1 -0
  99. package/dist/cascade/packer.js +106 -0
  100. package/dist/cascade/packer.js.map +1 -0
  101. package/dist/conversation/result-ops.d.ts +18 -3
  102. package/dist/conversation/result-ops.d.ts.map +1 -1
  103. package/dist/conversation/result-ops.js +77 -5
  104. package/dist/conversation/result-ops.js.map +1 -1
  105. package/dist/conversation/rolling-summary.d.ts.map +1 -1
  106. package/dist/conversation/rolling-summary.js +7 -2
  107. package/dist/conversation/rolling-summary.js.map +1 -1
  108. package/dist/conversation/session-store.d.ts +3 -0
  109. package/dist/conversation/session-store.d.ts.map +1 -1
  110. package/dist/conversation/session-store.js +8 -4
  111. package/dist/conversation/session-store.js.map +1 -1
  112. package/dist/conversation/snapshot.d.ts.map +1 -1
  113. package/dist/conversation/snapshot.js +13 -1
  114. package/dist/conversation/snapshot.js.map +1 -1
  115. package/dist/conversation/turn-trust.d.ts.map +1 -1
  116. package/dist/conversation/turn-trust.js +6 -0
  117. package/dist/conversation/turn-trust.js.map +1 -1
  118. package/dist/hints/store.d.ts.map +1 -1
  119. package/dist/hints/store.js +15 -5
  120. package/dist/hints/store.js.map +1 -1
  121. package/dist/index.d.ts +27 -5
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +28 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +8 -0
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +65 -0
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/meaning-resolution.d.ts +7 -0
  130. package/dist/meaning-resolution.d.ts.map +1 -1
  131. package/dist/meaning-resolution.js.map +1 -1
  132. package/dist/metadata/block-fit.d.ts +18 -0
  133. package/dist/metadata/block-fit.d.ts.map +1 -1
  134. package/dist/metadata/block-fit.js +41 -1
  135. package/dist/metadata/block-fit.js.map +1 -1
  136. package/dist/metadata/catalog.d.ts +44 -23
  137. package/dist/metadata/catalog.d.ts.map +1 -1
  138. package/dist/metadata/catalog.js +363 -53
  139. package/dist/metadata/catalog.js.map +1 -1
  140. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  141. package/dist/metadata/meaning-evidence.js +101 -8
  142. package/dist/metadata/meaning-evidence.js.map +1 -1
  143. package/dist/metadata/metric-match.d.ts +32 -0
  144. package/dist/metadata/metric-match.d.ts.map +1 -1
  145. package/dist/metadata/metric-match.js +120 -9
  146. package/dist/metadata/metric-match.js.map +1 -1
  147. package/dist/research-loop.d.ts +33 -2
  148. package/dist/research-loop.d.ts.map +1 -1
  149. package/dist/research-loop.js +44 -4
  150. package/dist/research-loop.js.map +1 -1
  151. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  152. package/dist/resolved-analytical-plan.js +92 -5
  153. package/dist/resolved-analytical-plan.js.map +1 -1
  154. package/dist/router.d.ts +36 -16
  155. package/dist/router.d.ts.map +1 -1
  156. package/dist/router.js +604 -25
  157. package/dist/router.js.map +1 -1
  158. package/package.json +4 -4
@@ -2,11 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { normalizeProviderEgressReceiptV1, } from '@duckcodeailabs/dql-core';
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
- import { classifyConversationalTurn, decideAgentAction, looksLikeComposeApp, } from "./intent-controller.js";
5
+ import { classifyConversationalTurn, looksLikeDefinitionalAboutNamedObject, decideAgentAction, looksLikeComposeApp, } from "./intent-controller.js";
6
6
  import { selectCascadeRunRoute } from "./cascade/route-policy.js";
7
7
  import { canUseEngineEscalation, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, recordEngineEscalation, recordLaneRepair, } from "./cascade/budgets.js";
8
8
  import { buildAnalysisQuestionPlan } from "./metadata/analysis-planner.js";
9
9
  import { conversationHistoryFromContext, isLikelyClarificationReply, } from "./conversation/snapshot.js";
10
+ import { buildCoverageGap } from './analytical-orchestration.js';
11
+ import { evaluateAnalyticalRequestPolicy } from './analytical-request-policy.js';
10
12
  /** Routes a stakeholder may never land on (analyst authoring lives in the Notebook). */
11
13
  const ANALYST_ONLY_ROUTES = new Set(["sql_cell", "dql_block_draft", "modeling_draft", "skill_draft"]);
12
14
  /**
@@ -37,6 +39,17 @@ export function compareResolvedPlanShadow(decision, actualRoute) {
37
39
  matches: plannedRoute === actualRoute,
38
40
  };
39
41
  }
42
+ /** Stable host-issued reason for a user cancellation. Provider AbortErrors do
43
+ * not carry this code and therefore remain distinguishable executor failures. */
44
+ export const AGENT_RUN_USER_CANCEL_CODE = "RUN_CANCELLED";
45
+ export function createAgentRunCancellationError() {
46
+ return Object.assign(new Error("Stopped by user."), { code: AGENT_RUN_USER_CANCEL_CODE });
47
+ }
48
+ export function isAgentRunUserCancellation(error) {
49
+ if (!error || typeof error !== "object")
50
+ return false;
51
+ return error.code === AGENT_RUN_USER_CANCEL_CODE;
52
+ }
40
53
  const DEFAULT_MAX_STEPS = 4;
41
54
  export function agentRouteDeadlineMs(route) {
42
55
  if (route === 'certified_answer' || route === 'semantic_answer')
@@ -53,30 +66,60 @@ export function agentRouteDeadlineMs(route) {
53
66
  return 120_000;
54
67
  return undefined;
55
68
  }
69
+ /** Default request-ingress deadlines. */
70
+ const DEFAULT_ASK_DEADLINE_MS = 45_000;
71
+ const DEFAULT_RESEARCH_DEADLINE_MS = 120_000;
72
+ /** Ceiling on any override, so a typo cannot hang a run indefinitely. */
73
+ const MAX_DEADLINE_MS = 600_000;
74
+ /**
75
+ * A deadline multiplier for slow providers.
76
+ *
77
+ * The 45s Ask budget assumes a hosted model. A local Ollama model needs ~7s for
78
+ * a one-word reply, so the meaning call alone can exhaust the whole window and
79
+ * every question comes back "The discovery window ended before an exact plan was
80
+ * frozen" — which makes a local model unusable at the default, and DQL is
81
+ * local-first by design.
82
+ *
83
+ * Deliberately a MULTIPLIER rather than an absolute: the relationship between
84
+ * the Ask and Research budgets, and between each and its soft targets, is
85
+ * load-bearing, so scaling keeps them proportional instead of letting one
86
+ * setting invert them.
87
+ */
88
+ export function deadlineScale(env = process.env) {
89
+ const raw = Number(env.DQL_AGENT_DEADLINE_SCALE);
90
+ if (!Number.isFinite(raw) || raw <= 0)
91
+ return 1;
92
+ // Bounded: below 1 would tighten a safety deadline someone is relying on.
93
+ return Math.min(Math.max(raw, 1), 20);
94
+ }
56
95
  /** The request envelope starts before retrieval/routing, so a stuck router can
57
96
  * never evade the route-specific deadline that is selected later. */
58
97
  export function agentRequestDeadlineMs(requestedMode) {
59
- return requestedMode === 'research' ? 120_000 : 45_000;
98
+ const base = requestedMode === 'research' ? DEFAULT_RESEARCH_DEADLINE_MS : DEFAULT_ASK_DEADLINE_MS;
99
+ return Math.min(base * deadlineScale(), MAX_DEADLINE_MS);
60
100
  }
61
101
  /** Create the one request-ingress deadline authority used by every stage. */
62
102
  export function createAgentRunBudget(input) {
63
103
  const nowMs = input.nowMs ?? Date.now;
64
104
  const startedAtMs = input.startedAtMs ?? nowMs();
65
105
  const mode = input.requestedMode === 'research' ? 'research' : 'ask';
66
- const hardDeadlineMs = mode === 'research' ? 120_000 : 45_000;
106
+ const scale = deadlineScale();
107
+ const hardDeadlineMs = Math.min((mode === 'research' ? DEFAULT_RESEARCH_DEADLINE_MS : DEFAULT_ASK_DEADLINE_MS) * scale, MAX_DEADLINE_MS);
67
108
  const timeout = (input.timeoutSignal ?? AbortSignal.timeout)(hardDeadlineMs);
68
109
  const hardSignal = input.inheritedSignal
69
110
  ? AbortSignal.any([input.inheritedSignal, timeout])
70
111
  : timeout;
71
112
  const elapsedMs = () => Math.max(0, nowMs() - startedAtMs);
72
113
  const softTargetMs = (route) => {
73
- if (mode === 'research')
74
- return 90_000;
75
- return agentRouteDeadlineMs(route) ?? 15_000;
114
+ const base = mode === 'research' ? 90_000 : (agentRouteDeadlineMs(route) ?? 15_000);
115
+ // Scaled with the hard deadline: a soft target that stayed fixed while the
116
+ // ceiling moved would stop new work long before the run was actually out of
117
+ // time, which is the same dead end by a different route.
118
+ return Math.min(base * scale, hardDeadlineMs);
76
119
  };
77
120
  // Narration must still be reachable after a full generation window, and must
78
121
  // leave the hard deadline (45s ask / 120s research) room to land.
79
- const narrationSoftTargetMs = () => (mode === 'research' ? 100_000 : 38_000);
122
+ const narrationSoftTargetMs = () => Math.min((mode === 'research' ? 100_000 : 38_000) * scale, hardDeadlineMs);
80
123
  return Object.freeze({
81
124
  startedAtMs,
82
125
  hardDeadlineMs,
@@ -602,6 +645,119 @@ export class AgentRunEngine {
602
645
  ...(clarificationContinuation ? { clarificationResolved: true } : {}),
603
646
  },
604
647
  });
648
+ // This check is intentionally before route selection. A restricted direct
649
+ // disclosure must not be embedded, retrieved, sent to a provider, value
650
+ // probed, or compiled merely to explain why it cannot be answered.
651
+ const ingressPolicy = evaluateAnalyticalRequestPolicy(submittedQuestion);
652
+ if (!ingressPolicy.allowed) {
653
+ const routeDecision = {
654
+ action: 'answer',
655
+ confidence: 1,
656
+ reason: 'The request is unavailable under the Ask data-safety policy.',
657
+ followsUp: false,
658
+ source: 'heuristic',
659
+ terminalOutcome: {
660
+ kind: 'policy_blocked',
661
+ code: 'ANALYTICAL_POLICY_BLOCKED',
662
+ message: ingressPolicy.message,
663
+ candidateIds: [],
664
+ },
665
+ };
666
+ const coverageGap = buildCoverageGap({
667
+ code: 'POLICY_BLOCKED',
668
+ phase: 'retrieval',
669
+ message: ingressPolicy.message,
670
+ searchedSources: [],
671
+ attemptedRoutes: [],
672
+ missing: [],
673
+ recoverable: false,
674
+ planFrozen: false,
675
+ nextActions: ingressPolicy.nextActions,
676
+ });
677
+ const evaluation = {
678
+ id: 'request-policy',
679
+ label: 'Ask request policy',
680
+ passed: false,
681
+ severity: 'blocking',
682
+ message: ingressPolicy.message,
683
+ };
684
+ const artifact = {
685
+ id: `${runId}:policy`,
686
+ kind: 'answer',
687
+ title: 'Ask request unavailable',
688
+ trustState: 'blocked',
689
+ payload: {
690
+ analyticalCoverageGap: coverageGap,
691
+ analyticalFailure: {
692
+ code: 'POLICY_BLOCKED',
693
+ phase: 'request_policy',
694
+ message: ingressPolicy.message,
695
+ recoverable: false,
696
+ safeActions: ingressPolicy.nextActions,
697
+ },
698
+ },
699
+ };
700
+ emit({
701
+ type: 'route.decided',
702
+ message: routeDecision.reason,
703
+ route: 'blocked',
704
+ payload: routeDecision,
705
+ });
706
+ emit({
707
+ type: 'evaluation.recorded',
708
+ message: evaluation.message,
709
+ route: 'blocked',
710
+ payload: evaluation,
711
+ });
712
+ emit({
713
+ type: 'artifact.created',
714
+ message: 'Created answer artifact.',
715
+ route: 'blocked',
716
+ trustState: 'blocked',
717
+ payload: artifact,
718
+ });
719
+ emit({
720
+ type: 'run.failed',
721
+ message: ingressPolicy.message,
722
+ route: 'blocked',
723
+ status: 'blocked',
724
+ trustState: 'blocked',
725
+ });
726
+ const completedAt = this.timestamp();
727
+ const run = {
728
+ id: runId,
729
+ question: submittedQuestion,
730
+ requestedMode,
731
+ route: 'blocked',
732
+ status: 'blocked',
733
+ trustState: 'blocked',
734
+ stopReason: 'blocked',
735
+ startedAt,
736
+ completedAt,
737
+ selectedObject: request.selectedObject,
738
+ executionTarget: request.executionTarget,
739
+ routeDecision,
740
+ steps: [],
741
+ summary: ingressPolicy.message,
742
+ answer: ingressPolicy.message,
743
+ answerKind: 'governed',
744
+ artifacts: [artifact],
745
+ evaluations: [evaluation],
746
+ events,
747
+ nextActions: ingressPolicy.nextActions.map((id) => ({ id, label: id === 'ask_for_an_approved_aggregate' ? 'Ask for an approved aggregate' : 'Inspect data policy' })),
748
+ repairAttempts: 0,
749
+ escalationAttempts: 0,
750
+ budgetUsage: cascadeBudgetTrace(createCascadeBudgetState(this.budgetModel)),
751
+ telemetry: emptyRunTelemetry(durationBetweenMs(startedAt, completedAt), 'policy_blocked'),
752
+ lifecycle: terminalLifecycle(progress.lifecycle, 'run.failed', completedAt, events.length),
753
+ };
754
+ run.diagnosticReceipt = diagnosticReceiptForRun(run);
755
+ run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
756
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2);
757
+ await checkpointQueue;
758
+ await this.store?.save(run);
759
+ return run;
760
+ }
605
761
  const audience = resolveAudience(request);
606
762
  // Initialize a deterministic decision so router/provider timeouts can still
607
763
  // be persisted as a complete blocked run with an inspectable trace. The old
@@ -711,11 +867,17 @@ export class AgentRunEngine {
711
867
  attempt,
712
868
  stepGoal: planned.goal,
713
869
  priorEvaluations,
870
+ priorArtifacts: progress.artifacts,
714
871
  repairHint,
715
872
  emit,
716
873
  emitAnswerDelta: onAnswerDelta,
717
874
  });
718
875
  result = consumeRepeatedClarificationSelection(request, routeDecision, result);
876
+ if (result.analyticalTurnPlan)
877
+ progress.analyticalTurnPlan = result.analyticalTurnPlan;
878
+ if (result.analyticalTaskOutcomes)
879
+ progress.analyticalTaskOutcomes = result.analyticalTaskOutcomes;
880
+ persistProgress();
719
881
  evaluations = this.evaluate({ route, request, routeDecision, result, attempt });
720
882
  for (const evaluation of evaluations) {
721
883
  emit({
@@ -742,23 +904,6 @@ export class AgentRunEngine {
742
904
  stepStatus = 'needs_review';
743
905
  break;
744
906
  }
745
- // Ordinary generated Ask freezes one analytical attempt. A failed
746
- // attempt is terminal review/manual-action work; it cannot hand
747
- // authority back to an injected LLM replanner for another physical
748
- // provider send. Explicit Research and authoring modes retain their
749
- // separately budgeted planner behavior.
750
- const ordinaryGeneratedAsk = route === 'generated_answer'
751
- && (requestedMode === 'ask' || requestedMode === 'auto');
752
- if (ordinaryGeneratedAsk) {
753
- stepStatus = 'needs_review';
754
- emit({
755
- type: 'replan.decided',
756
- message: 'Ordinary Ask kept the frozen analytical attempt and stopped for review without replanning.',
757
- route,
758
- payload: { decision: 'accept', authority: 'deterministic_fail_closed' },
759
- });
760
- break;
761
- }
762
907
  const currentStep = {
763
908
  id: stepId,
764
909
  index: stepCount,
@@ -967,6 +1112,89 @@ export class AgentRunEngine {
967
1112
  }
968
1113
  catch (err) {
969
1114
  const dispatchEvidence = providerDispatchEvidenceFromError(err);
1115
+ const userCancelled = isAgentRunUserCancellation(request.signal?.reason) || isAgentRunUserCancellation(err);
1116
+ if (userCancelled) {
1117
+ const message = "Stopped by user.";
1118
+ const cancelledRoute = progress.route ?? "cancelled";
1119
+ const cancelledPhase = progress.lifecycle.phase;
1120
+ emit({
1121
+ type: "run.cancelled",
1122
+ message,
1123
+ route: cancelledRoute,
1124
+ status: "cancelled",
1125
+ trustState: "not_applicable",
1126
+ });
1127
+ const completedAt = this.timestamp();
1128
+ const failure = {
1129
+ code: AGENT_RUN_USER_CANCEL_CODE,
1130
+ phase: cancelledPhase,
1131
+ message,
1132
+ recoverable: false,
1133
+ safeActions: [],
1134
+ };
1135
+ const evaluations = [
1136
+ ...progress.evaluations,
1137
+ {
1138
+ id: "run-cancelled",
1139
+ label: "Run cancelled",
1140
+ passed: true,
1141
+ severity: "info",
1142
+ message,
1143
+ },
1144
+ ];
1145
+ const receipt = {
1146
+ version: 1,
1147
+ runId,
1148
+ phase: failure.phase,
1149
+ route: cancelledRoute,
1150
+ plan,
1151
+ steps: executedSteps,
1152
+ artifacts: progress.artifacts,
1153
+ evaluations,
1154
+ failure,
1155
+ };
1156
+ const run = {
1157
+ id: runId,
1158
+ question: submittedQuestion,
1159
+ requestedMode,
1160
+ route: cancelledRoute,
1161
+ status: "cancelled",
1162
+ trustState: "not_applicable",
1163
+ stopReason: "cancelled",
1164
+ startedAt,
1165
+ completedAt,
1166
+ selectedObject: request.selectedObject,
1167
+ executionTarget: request.executionTarget,
1168
+ routeDecision,
1169
+ plan,
1170
+ steps: executedSteps,
1171
+ summary: message,
1172
+ artifacts: progress.artifacts,
1173
+ evaluations,
1174
+ events,
1175
+ nextActions: [],
1176
+ repairAttempts: 0,
1177
+ escalationAttempts: 0,
1178
+ budgetUsage: cascadeBudgetTrace(createCascadeBudgetState(this.budgetModel)),
1179
+ diagnosticReceipt: receipt,
1180
+ ...(dispatchEvidence.providerEgressReceipts.length
1181
+ ? { providerEgressReceipts: dispatchEvidence.providerEgressReceipts }
1182
+ : {}),
1183
+ telemetry: {
1184
+ ...emptyRunTelemetry(durationBetweenMs(startedAt, completedAt), "cancelled"),
1185
+ providerRoundTrips: dispatchEvidence.providerRoundTrips,
1186
+ toolCalls: dispatchEvidence.toolCalls,
1187
+ sqlExecutions: dispatchEvidence.sqlExecutions,
1188
+ repairs: dispatchEvidence.repairs,
1189
+ egressReceipts: dispatchEvidence.providerEgressReceipts.length,
1190
+ },
1191
+ lifecycle: terminalLifecycle(progress.lifecycle, "run.cancelled", completedAt, events.length),
1192
+ };
1193
+ run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1194
+ await checkpointQueue;
1195
+ await this.store?.save(run);
1196
+ return run;
1197
+ }
970
1198
  const message = isOrchestrationBudgetExhausted(err)
971
1199
  ? 'Ask could not complete within its bounded orchestration. Nothing was executed; narrow the metric or dimension and retry.'
972
1200
  : err instanceof Error && err.name === "TimeoutError"
@@ -1138,8 +1366,13 @@ export class AgentRunEngine {
1138
1366
  ...(finalResult.telemetry ? {
1139
1367
  telemetry: withTotalDuration(finalResult.telemetry, durationBetweenMs(input.startedAt, completedAt)),
1140
1368
  } : {}),
1369
+ ...(finalResult.narrationIntegrityReceipt ? {
1370
+ narrationIntegrityReceipt: finalResult.narrationIntegrityReceipt,
1371
+ } : {}),
1141
1372
  escalationAttempts,
1142
1373
  budgetUsage: input.budgetUsage,
1374
+ ...(finalResult.analyticalTurnPlan ? { analyticalTurnPlan: finalResult.analyticalTurnPlan } : {}),
1375
+ ...(finalResult.analyticalTaskOutcomes ? { analyticalTaskOutcomes: finalResult.analyticalTaskOutcomes } : {}),
1143
1376
  ...authoringDerivationFromRequest(input.request),
1144
1377
  };
1145
1378
  }
@@ -1162,7 +1395,7 @@ export class AgentRunEngine {
1162
1395
  && !frozenPlan
1163
1396
  && context.request.runBudget
1164
1397
  && !context.request.runBudget.mayStartDiscovery(context.route)) {
1165
- return softBoundaryResult(context.route, context.request.runBudget);
1398
+ return softBoundaryResult(context.route, context.request.runBudget, context.priorArtifacts);
1166
1399
  }
1167
1400
  const signal = context.request.runBudget?.hardSignal ?? context.request.signal;
1168
1401
  if (signal?.aborted)
@@ -1194,6 +1427,112 @@ export class AgentRunEngine {
1194
1427
  * inject or deserialize an older router decision as well as the canonical
1195
1428
  * router producer.
1196
1429
  */
1430
+ /** An ordinary Ask turn (not Research, App Build, or another explicit mode). */
1431
+ function isOrdinaryAskRequest(request) {
1432
+ return request.requestedMode === undefined
1433
+ || request.requestedMode === 'auto'
1434
+ || request.requestedMode === 'ask';
1435
+ }
1436
+ /**
1437
+ * A modeling/coverage gap is a pre-freeze DISCOVERY result, not permission to
1438
+ * terminate an ordinary Ask. Keep the diagnostic as a typed reason, clear the
1439
+ * blocked RAP, and let the answer executor continue through the governed
1440
+ * relational and review-required generated lanes (AGT-028, EXP-001).
1441
+ *
1442
+ * `requireGovernedEvidence` separates the two callers, and it is what keeps the
1443
+ * analytical-RAP safety boundary intact:
1444
+ *
1445
+ * - A gap the ROUTER reported is already proof that discovery ran and found a
1446
+ * modelling limit, so it is rescued unconditionally (the original AGT-028
1447
+ * behaviour).
1448
+ * - A block this module SYNTHESIZES only means "nothing froze here". That is
1449
+ * also what an absent, LLM-authored, or forged router decision looks like,
1450
+ * so it is rescued only when retrieval actually surfaced governed
1451
+ * candidates. With no evidence at all it must still fail closed — a forged
1452
+ * `converse`/`answer` must never bypass the boundary on an analytical
1453
+ * question.
1454
+ *
1455
+ * Returns `undefined` when the decision is not a rescuable modeling gap, so
1456
+ * callers can fall through to their own handling.
1457
+ */
1458
+ function rescueModelingGapForOrdinaryAsk(request, decision, options = {}) {
1459
+ if (!isOrdinaryAskRequest(request))
1460
+ return undefined;
1461
+ if (decision.terminalOutcome?.kind !== 'modeling_gap')
1462
+ return undefined;
1463
+ // A missing modeled dimension is a factual coverage result, not an invitation
1464
+ // to retry the same question through generated SQL or Research. The router
1465
+ // already searched the governed sources and attached the exact field; keep it
1466
+ // terminal so the UI can render the typed modeling action.
1467
+ if (decision.meaningResolution?.compatibilityFailures?.some((failure) => failure.code === 'MISSING_DIMENSION'))
1468
+ return undefined;
1469
+ // Genuine user-facing ambiguity and an explicit evidence pick stay terminal.
1470
+ if (decision.requiresClarification === true)
1471
+ return undefined;
1472
+ if (request.selectedEvidenceId)
1473
+ return undefined;
1474
+ if (options.requireGovernedEvidence) {
1475
+ const governedEvidence = (decision.retrievalEvidence?.candidateCount ?? 0) > 0
1476
+ || (decision.meaningResolution?.selectedConceptIds.length ?? 0) > 0;
1477
+ if (!governedEvidence)
1478
+ return undefined;
1479
+ }
1480
+ return {
1481
+ ...decision,
1482
+ action: 'answer',
1483
+ confidence: Math.min(decision.confidence, 0.55),
1484
+ reason: `${decision.terminalOutcome.message} Continuing through DBT-grounded relational and review-required generated analysis before asking for a modeling change.`,
1485
+ terminalOutcome: undefined,
1486
+ resolvedAnalyticalPlan: undefined,
1487
+ requiresClarification: false,
1488
+ // The premise of this rescue is that NO governed plan froze, so the turn
1489
+ // must not take `selectRoute`'s certified/semantic shortcut — that path
1490
+ // stamps `certified`/`governed` trust off a bare RECOMMENDATION, which is a
1491
+ // suggestion, not a bound execution contract. Keep the meaning evidence for
1492
+ // citations and downgrade the route to governed SQL, which is review-required.
1493
+ ...(decision.meaningResolution
1494
+ ? {
1495
+ meaningResolution: {
1496
+ ...decision.meaningResolution,
1497
+ recommendedRoute: 'governed_sql',
1498
+ },
1499
+ }
1500
+ : {}),
1501
+ };
1502
+ }
1503
+ /**
1504
+ * Has this exact clarification already been asked in this thread?
1505
+ *
1506
+ * The reported loop: "who are the top customers for BCM" returned "Top by which
1507
+ * governed metric?", the user answered in prose, and the IDENTICAL question came
1508
+ * back. Their reply reads as a complete new question — which is a defensible
1509
+ * classification — so it re-entered the cascade fresh, retrieved the same
1510
+ * evidence, and produced the same clarification. Nothing in the loop noticed it
1511
+ * had already been there.
1512
+ *
1513
+ * Asking twice is proof the question does not work: the user has already seen it
1514
+ * and responded, and a third identical prompt cannot produce a different reply.
1515
+ * Whatever the best available interpretation is, committing to it and saying so
1516
+ * beats asking again.
1517
+ */
1518
+ function clarificationAlreadyAsked(clarifyingQuestion, history) {
1519
+ const asking = clarifyingQuestion?.replace(/\s+/g, ' ').trim().toLowerCase();
1520
+ if (!asking || !history?.length)
1521
+ return false;
1522
+ let sawAssistantAsk = false;
1523
+ for (const turn of history) {
1524
+ if (turn.role !== 'assistant')
1525
+ continue;
1526
+ if (turn.text.replace(/\s+/g, ' ').trim().toLowerCase().includes(asking))
1527
+ sawAssistantAsk = true;
1528
+ }
1529
+ if (!sawAssistantAsk)
1530
+ return false;
1531
+ // Only a loop once the user has actually replied to it. A clarification still
1532
+ // waiting for its first answer is not a repeat.
1533
+ const lastAsk = [...history].reverse().findIndex((turn) => turn.role === 'assistant' && turn.text.replace(/\s+/g, ' ').trim().toLowerCase().includes(asking));
1534
+ return lastAsk > 0;
1535
+ }
1197
1536
  function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1198
1537
  const ordinaryAsk = request.requestedMode === undefined
1199
1538
  || request.requestedMode === 'auto'
@@ -1202,6 +1541,52 @@ function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1202
1541
  || decision.action === 'block'
1203
1542
  || decision.requiresClarification === true
1204
1543
  || Boolean(decision.terminalOutcome);
1544
+ const inboundRescue = rescueModelingGapForOrdinaryAsk(request, decision);
1545
+ if (inboundRescue)
1546
+ return inboundRescue;
1547
+ // A clarification the user has already seen and answered cannot be asked
1548
+ // again. Checked before the `terminal` guard for the same reason as the
1549
+ // definitional case: the gate has already set `action: 'clarify'` by here.
1550
+ if (ordinaryAsk
1551
+ && decision.action === 'clarify'
1552
+ && clarificationAlreadyAsked(decision.clarifyingQuestion, request.history?.length ? request.history : conversationHistoryFromContext(request.conversationContext))) {
1553
+ return {
1554
+ ...decision,
1555
+ action: 'answer',
1556
+ confidence: Math.min(decision.confidence, 0.6),
1557
+ reason: `This clarification was already asked and answered in this thread, so DQL proceeded with the best supported interpretation instead of repeating it: ${decision.clarifyingQuestion ?? ''}`.trim(),
1558
+ requiresClarification: false,
1559
+ clarifyingQuestion: undefined,
1560
+ terminalOutcome: undefined,
1561
+ resolvedAnalyticalPlan: undefined,
1562
+ };
1563
+ }
1564
+ // A DEFINITIONAL question about an artifact it names is not an analytical
1565
+ // request, and must be caught before the `terminal` guard below — the
1566
+ // ambiguity gate has already set `action: 'clarify'` by this point, so any
1567
+ // check placed after it is unreachable.
1568
+ //
1569
+ // Without this, "what is food_vs_drink_revenue?" is answered with "Which
1570
+ // governed meaning should DQL bind: food_vs_drink_revenue or …?" — asking the
1571
+ // user to disambiguate the one artifact they just named. The plan cannot see
1572
+ // it because it reads the artifact's OWN NAME as analytical intent: that name
1573
+ // contains "vs", so the mode comes back `comparison`.
1574
+ if (ordinaryAsk
1575
+ && !request.selectedEvidenceId
1576
+ && looksLikeDefinitionalAboutNamedObject(request.question, decision.retrievalEvidence?.candidateIds ?? [])) {
1577
+ return {
1578
+ ...decision,
1579
+ action: 'converse',
1580
+ category: 'conversational',
1581
+ confidence: 1,
1582
+ reason: 'This asks what a governed artifact means, so it is answered from its definition rather than by running a query.',
1583
+ requiresClarification: false,
1584
+ clarifyingQuestion: undefined,
1585
+ clarificationOptions: undefined,
1586
+ terminalOutcome: undefined,
1587
+ resolvedAnalyticalPlan: undefined,
1588
+ };
1589
+ }
1205
1590
  const exactSemanticContinuation = Boolean(request.selectedEvidenceId
1206
1591
  && decision.meaningResolution?.recommendedRoute === 'semantic'
1207
1592
  && decision.meaningResolution.recommendedExecutionId === request.selectedEvidenceId);
@@ -1264,7 +1649,7 @@ function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1264
1649
  if (!analytical)
1265
1650
  return decision;
1266
1651
  const message = 'DQL could not freeze an exact analytical plan for the requested metric, grain, filters, ordering, and outputs. Choose a governed identifier or model the missing capability before retrying.';
1267
- return {
1652
+ const blocked = {
1268
1653
  ...decision,
1269
1654
  action: 'block',
1270
1655
  confidence: 1,
@@ -1277,9 +1662,40 @@ function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1277
1662
  candidateIds: decision.retrievalEvidence?.candidateIds ?? [],
1278
1663
  },
1279
1664
  };
1665
+ // The block synthesized HERE is a modeling gap too, so it must face the same
1666
+ // rescue as one arriving from the router. Without this, the rescue above
1667
+ // silently did not apply to the most common way an ordinary Ask gets blocked:
1668
+ // retrieval found governed candidates, no exact tuple froze, and the run
1669
+ // dead-ended instead of continuing to the generated lane.
1670
+ return rescueModelingGapForOrdinaryAsk(request, blocked, { requireGovernedEvidence: true }) ?? blocked;
1280
1671
  }
1281
- function softBoundaryResult(route, budget) {
1672
+ function softBoundaryResult(route, budget, priorArtifacts = []) {
1282
1673
  const seconds = Math.round(budget.softTargetMs(route) / 1_000);
1674
+ // Admission control must never turn work that already validated into a
1675
+ // refusal. A run that established findings and then ran out of clock has
1676
+ // something true to say; answering `blocked` discards it and asks the user
1677
+ // to start the same investigation over. Partial and labelled beats nothing.
1678
+ const established = priorArtifacts.filter((artifact) => artifact.trustState !== "blocked" && artifact.trustState !== "not_applicable");
1679
+ if (established.length > 0) {
1680
+ const titles = established.map((artifact) => artifact.title).filter((title) => Boolean(title));
1681
+ const named = titles.length === 1
1682
+ ? titles[0]
1683
+ : `${titles.slice(0, -1).join(", ")} and ${titles[titles.length - 1]}`;
1684
+ const plural = established.length === 1;
1685
+ return {
1686
+ resolvedRoute: route,
1687
+ status: "needs_review",
1688
+ trustState: "review_required",
1689
+ stopReason: "human_review_required",
1690
+ summary: `The ${seconds}-second discovery target elapsed. Returning what was established rather than discarding it.`,
1691
+ answer: titles.length > 0
1692
+ ? `The discovery window ended before the whole question was covered, so this is partial: ${named}. ${plural ? "It was" : "They were"} validated before the clock ran out; nothing beyond ${plural ? "it" : "them"} was investigated. Continue to pick up from here.`
1693
+ : `The discovery window ended before the whole question was covered. ${established.length} validated finding${plural ? "" : "s"} from this run ${plural ? "is" : "are"} attached; nothing beyond ${plural ? "it" : "them"} was investigated.`,
1694
+ artifacts: [...established],
1695
+ evaluations: [],
1696
+ nextActions: [{ id: "continue-after-soft-target", label: "Continue the investigation" }],
1697
+ };
1698
+ }
1283
1699
  return {
1284
1700
  resolvedRoute: 'clarify',
1285
1701
  status: 'needs_clarification',
@@ -1410,13 +1826,13 @@ function diagnosticFailureFromError(error, phase) {
1410
1826
  safeActions: ["retry_same_plan"],
1411
1827
  };
1412
1828
  }
1413
- if (lower.includes("stopped by user") || lower.includes("cancel")) {
1829
+ if (isAgentRunUserCancellation(error)) {
1414
1830
  return {
1415
1831
  code: "RUN_CANCELLED",
1416
1832
  phase,
1417
- message,
1418
- recoverable: true,
1419
- safeActions: ["retry_same_request"],
1833
+ message: "Stopped by user.",
1834
+ recoverable: false,
1835
+ safeActions: [],
1420
1836
  };
1421
1837
  }
1422
1838
  return {
@@ -1454,7 +1870,7 @@ function diagnosticReceiptForRun(run) {
1454
1870
  return {
1455
1871
  version: 1,
1456
1872
  runId: run.id,
1457
- phase: run.lifecycle?.phase ?? (run.status === "blocked" ? "run.failed" : "run.completed"),
1873
+ phase: run.lifecycle?.phase ?? (run.status === "blocked" ? "run.failed" : run.status === "cancelled" ? "run.cancelled" : "run.completed"),
1458
1874
  route: run.route,
1459
1875
  ...(run.routeDecision?.resolvedAnalyticalPlan
1460
1876
  ? { resolvedAnalyticalPlan: run.routeDecision.resolvedAnalyticalPlan }
@@ -1465,14 +1881,14 @@ function diagnosticReceiptForRun(run) {
1465
1881
  evaluations: run.evaluations,
1466
1882
  ...(run.repairCapability ? { repairCapability: run.repairCapability } : {}),
1467
1883
  ...(run.providerEgressReceipts?.length ? { providerEgressReceipts: run.providerEgressReceipts } : {}),
1468
- ...(run.status === "blocked"
1884
+ ...(run.status === "blocked" || run.status === "cancelled"
1469
1885
  ? {
1470
1886
  failure: {
1471
- code: failureCode,
1472
- phase: run.lifecycle?.phase ?? "run.failed",
1473
- message: failureEvaluation?.message ?? run.summary,
1474
- recoverable: Boolean(run.nextActions.length),
1475
- safeActions: run.nextActions.map((action) => action.id),
1887
+ code: run.status === "cancelled" ? AGENT_RUN_USER_CANCEL_CODE : failureCode,
1888
+ phase: run.lifecycle?.phase ?? (run.status === "cancelled" ? "run.cancelled" : "run.failed"),
1889
+ message: run.status === "cancelled" ? "Stopped by user." : failureEvaluation?.message ?? run.summary,
1890
+ recoverable: run.status === "cancelled" ? false : Boolean(run.nextActions.length),
1891
+ safeActions: run.status === "cancelled" ? [] : run.nextActions.map((action) => action.id),
1476
1892
  },
1477
1893
  }
1478
1894
  : {}),
@@ -2007,6 +2423,8 @@ function defaultArtifacts(route, result, request) {
2007
2423
  }
2008
2424
  }
2009
2425
  function stopReasonFor(route, status, trustState, artifacts) {
2426
+ if (status === "cancelled")
2427
+ return "cancelled";
2010
2428
  if (status === "blocked" || trustState === "blocked")
2011
2429
  return "blocked";
2012
2430
  if (route === "conversation")
@@ -2024,7 +2442,7 @@ function stopReasonFor(route, status, trustState, artifacts) {
2024
2442
  return "generated_review_required";
2025
2443
  }
2026
2444
  function defaultNextActions(route, status) {
2027
- if (status === "blocked")
2445
+ if (status === "blocked" || status === "cancelled")
2028
2446
  return [];
2029
2447
  if (route === "certified_answer") {
2030
2448
  return [