@duckcodeailabs/dql-agent 1.13.4 → 1.14.0

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 (137) hide show
  1. package/dist/agent-run-engine.d.ts +87 -7
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +559 -40
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-store.d.ts.map +1 -1
  6. package/dist/agent-run-store.js +42 -25
  7. package/dist/agent-run-store.js.map +1 -1
  8. package/dist/agentic/tool-loop.d.ts.map +1 -1
  9. package/dist/agentic/tool-loop.js +100 -32
  10. package/dist/agentic/tool-loop.js.map +1 -1
  11. package/dist/aggregation-safety-proof.d.ts +41 -0
  12. package/dist/aggregation-safety-proof.d.ts.map +1 -0
  13. package/dist/aggregation-safety-proof.js +511 -0
  14. package/dist/aggregation-safety-proof.js.map +1 -0
  15. package/dist/analytical-compatibility.d.ts.map +1 -1
  16. package/dist/analytical-compatibility.js +42 -25
  17. package/dist/analytical-compatibility.js.map +1 -1
  18. package/dist/analytical-execution-graph.d.ts +4 -21
  19. package/dist/analytical-execution-graph.d.ts.map +1 -1
  20. package/dist/analytical-execution-graph.js +27 -0
  21. package/dist/analytical-execution-graph.js.map +1 -1
  22. package/dist/analytical-frame.d.ts +12 -1
  23. package/dist/analytical-frame.d.ts.map +1 -1
  24. package/dist/analytical-frame.js +102 -2
  25. package/dist/analytical-frame.js.map +1 -1
  26. package/dist/analytical-orchestration.d.ts +256 -0
  27. package/dist/analytical-orchestration.d.ts.map +1 -0
  28. package/dist/analytical-orchestration.js +419 -0
  29. package/dist/analytical-orchestration.js.map +1 -0
  30. package/dist/analytical-result-facts.d.ts +54 -1
  31. package/dist/analytical-result-facts.d.ts.map +1 -1
  32. package/dist/analytical-result-facts.js +183 -0
  33. package/dist/analytical-result-facts.js.map +1 -1
  34. package/dist/answer-loop.d.ts +44 -3
  35. package/dist/answer-loop.d.ts.map +1 -1
  36. package/dist/answer-loop.js +771 -38
  37. package/dist/answer-loop.js.map +1 -1
  38. package/dist/cascade/budgets.js +1 -1
  39. package/dist/cascade/route-policy.d.ts +1 -1
  40. package/dist/cascade/route-policy.d.ts.map +1 -1
  41. package/dist/cascade/route-policy.js +2 -0
  42. package/dist/cascade/route-policy.js.map +1 -1
  43. package/dist/conversation/snapshot.d.ts.map +1 -1
  44. package/dist/conversation/snapshot.js +3 -0
  45. package/dist/conversation/snapshot.js.map +1 -1
  46. package/dist/conversation/turn-trust.d.ts.map +1 -1
  47. package/dist/conversation/turn-trust.js +6 -0
  48. package/dist/conversation/turn-trust.js.map +1 -1
  49. package/dist/generated-analytical-proposal.d.ts +37 -0
  50. package/dist/generated-analytical-proposal.d.ts.map +1 -0
  51. package/dist/generated-analytical-proposal.js +219 -0
  52. package/dist/generated-analytical-proposal.js.map +1 -0
  53. package/dist/index.d.ts +11 -5
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +7 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/intent-controller.d.ts +8 -1
  58. package/dist/intent-controller.d.ts.map +1 -1
  59. package/dist/intent-controller.js.map +1 -1
  60. package/dist/kg/build.js +102 -6
  61. package/dist/kg/build.js.map +1 -1
  62. package/dist/meaning-resolution.d.ts +30 -0
  63. package/dist/meaning-resolution.d.ts.map +1 -1
  64. package/dist/meaning-resolution.js +61 -8
  65. package/dist/meaning-resolution.js.map +1 -1
  66. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  67. package/dist/metadata/analysis-planner.js +46 -2
  68. package/dist/metadata/analysis-planner.js.map +1 -1
  69. package/dist/metadata/block-fit.d.ts.map +1 -1
  70. package/dist/metadata/block-fit.js +55 -2
  71. package/dist/metadata/block-fit.js.map +1 -1
  72. package/dist/metadata/catalog.d.ts +24 -0
  73. package/dist/metadata/catalog.d.ts.map +1 -1
  74. package/dist/metadata/catalog.js +227 -42
  75. package/dist/metadata/catalog.js.map +1 -1
  76. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  77. package/dist/metadata/meaning-evidence.js +408 -36
  78. package/dist/metadata/meaning-evidence.js.map +1 -1
  79. package/dist/metadata/sql-context-validation.d.ts +3 -0
  80. package/dist/metadata/sql-context-validation.d.ts.map +1 -1
  81. package/dist/metadata/sql-context-validation.js +16 -5
  82. package/dist/metadata/sql-context-validation.js.map +1 -1
  83. package/dist/plan-execution-adapter.d.ts +9 -1
  84. package/dist/plan-execution-adapter.d.ts.map +1 -1
  85. package/dist/plan-execution-adapter.js +81 -32
  86. package/dist/plan-execution-adapter.js.map +1 -1
  87. package/dist/provider-egress.d.ts +122 -0
  88. package/dist/provider-egress.d.ts.map +1 -0
  89. package/dist/provider-egress.js +504 -0
  90. package/dist/provider-egress.js.map +1 -0
  91. package/dist/providers/claude.d.ts.map +1 -1
  92. package/dist/providers/claude.js +76 -33
  93. package/dist/providers/claude.js.map +1 -1
  94. package/dist/providers/dispatch.d.ts +15 -0
  95. package/dist/providers/dispatch.d.ts.map +1 -0
  96. package/dist/providers/dispatch.js +25 -0
  97. package/dist/providers/dispatch.js.map +1 -0
  98. package/dist/providers/gemini.d.ts.map +1 -1
  99. package/dist/providers/gemini.js +16 -8
  100. package/dist/providers/gemini.js.map +1 -1
  101. package/dist/providers/index.d.ts +2 -1
  102. package/dist/providers/index.d.ts.map +1 -1
  103. package/dist/providers/index.js +1 -0
  104. package/dist/providers/index.js.map +1 -1
  105. package/dist/providers/ollama.d.ts.map +1 -1
  106. package/dist/providers/ollama.js +29 -10
  107. package/dist/providers/ollama.js.map +1 -1
  108. package/dist/providers/openai.d.ts.map +1 -1
  109. package/dist/providers/openai.js +92 -36
  110. package/dist/providers/openai.js.map +1 -1
  111. package/dist/providers/types.d.ts +43 -0
  112. package/dist/providers/types.d.ts.map +1 -1
  113. package/dist/providers/types.js.map +1 -1
  114. package/dist/relationship-proof.d.ts +19 -0
  115. package/dist/relationship-proof.d.ts.map +1 -0
  116. package/dist/relationship-proof.js +75 -0
  117. package/dist/relationship-proof.js.map +1 -0
  118. package/dist/research-loop.d.ts.map +1 -1
  119. package/dist/research-loop.js +17 -11
  120. package/dist/research-loop.js.map +1 -1
  121. package/dist/resolved-analytical-plan.d.ts +25 -3
  122. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  123. package/dist/resolved-analytical-plan.js +460 -31
  124. package/dist/resolved-analytical-plan.js.map +1 -1
  125. package/dist/router.d.ts +38 -1
  126. package/dist/router.d.ts.map +1 -1
  127. package/dist/router.js +1060 -53
  128. package/dist/router.js.map +1 -1
  129. package/dist/semantic-identities.d.ts +18 -0
  130. package/dist/semantic-identities.d.ts.map +1 -0
  131. package/dist/semantic-identities.js +60 -0
  132. package/dist/semantic-identities.js.map +1 -0
  133. package/dist/semantic-proof-authority.d.ts +29 -0
  134. package/dist/semantic-proof-authority.d.ts.map +1 -0
  135. package/dist/semantic-proof-authority.js +34 -0
  136. package/dist/semantic-proof-authority.js.map +1 -0
  137. package/package.json +4 -4
@@ -1,20 +1,22 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { normalizeProviderEgressReceiptV1, } from '@duckcodeailabs/dql-core';
2
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
4
- import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
5
+ import { classifyConversationalTurn, decideAgentAction, looksLikeComposeApp, } from "./intent-controller.js";
5
6
  import { selectCascadeRunRoute } from "./cascade/route-policy.js";
6
7
  import { canUseEngineEscalation, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, recordEngineEscalation, recordLaneRepair, } from "./cascade/budgets.js";
8
+ import { buildAnalysisQuestionPlan } from "./metadata/analysis-planner.js";
7
9
  import { conversationHistoryFromContext, isLikelyClarificationReply, } from "./conversation/snapshot.js";
8
10
  /** Routes a stakeholder may never land on (analyst authoring lives in the Notebook). */
9
11
  const ANALYST_ONLY_ROUTES = new Set(["sql_cell", "dql_block_draft", "modeling_draft", "skill_draft"]);
10
12
  /**
11
- * Compare the pre-execution plan with the legacy route without changing the
12
- * route. This is the R3 shadow-mode cutover signal used before R4 enforcement.
13
+ * Compare the pre-execution plan with the selected route without changing it.
14
+ * Authoritative plans never emit or persist this migration-only diagnostic.
13
15
  * Acceptance: AGT-013, API-006.
14
16
  */
15
17
  export function compareResolvedPlanShadow(decision, actualRoute) {
16
18
  const plan = decision.resolvedAnalyticalPlan;
17
- if (!plan)
19
+ if (!plan || plan.mode !== 'shadow')
18
20
  return undefined;
19
21
  const plannedRoute = decision.action === 'investigate'
20
22
  ? 'research'
@@ -35,7 +37,70 @@ export function compareResolvedPlanShadow(decision, actualRoute) {
35
37
  matches: plannedRoute === actualRoute,
36
38
  };
37
39
  }
40
+ /** Stable host-issued reason for a user cancellation. Provider AbortErrors do
41
+ * not carry this code and therefore remain distinguishable executor failures. */
42
+ export const AGENT_RUN_USER_CANCEL_CODE = "RUN_CANCELLED";
43
+ export function createAgentRunCancellationError() {
44
+ return Object.assign(new Error("Stopped by user."), { code: AGENT_RUN_USER_CANCEL_CODE });
45
+ }
46
+ export function isAgentRunUserCancellation(error) {
47
+ if (!error || typeof error !== "object")
48
+ return false;
49
+ return error.code === AGENT_RUN_USER_CANCEL_CODE;
50
+ }
38
51
  const DEFAULT_MAX_STEPS = 4;
52
+ export function agentRouteDeadlineMs(route) {
53
+ if (route === 'certified_answer' || route === 'semantic_answer')
54
+ return 5_000;
55
+ if (route === 'clarify')
56
+ return 10_000;
57
+ // Generation may now take a genuine tool round (look something up, then use
58
+ // it) instead of a single blind shot, so the discovery window has to cover it.
59
+ // Subscription-CLI providers cost roughly 10-15s per dispatch, so a window
60
+ // under ~30s silently reduces the loop back to a single blind attempt.
61
+ if (route === 'generated_answer')
62
+ return 30_000;
63
+ if (route === 'research')
64
+ return 120_000;
65
+ return undefined;
66
+ }
67
+ /** The request envelope starts before retrieval/routing, so a stuck router can
68
+ * never evade the route-specific deadline that is selected later. */
69
+ export function agentRequestDeadlineMs(requestedMode) {
70
+ return requestedMode === 'research' ? 120_000 : 45_000;
71
+ }
72
+ /** Create the one request-ingress deadline authority used by every stage. */
73
+ export function createAgentRunBudget(input) {
74
+ const nowMs = input.nowMs ?? Date.now;
75
+ const startedAtMs = input.startedAtMs ?? nowMs();
76
+ const mode = input.requestedMode === 'research' ? 'research' : 'ask';
77
+ const hardDeadlineMs = mode === 'research' ? 120_000 : 45_000;
78
+ const timeout = (input.timeoutSignal ?? AbortSignal.timeout)(hardDeadlineMs);
79
+ const hardSignal = input.inheritedSignal
80
+ ? AbortSignal.any([input.inheritedSignal, timeout])
81
+ : timeout;
82
+ const elapsedMs = () => Math.max(0, nowMs() - startedAtMs);
83
+ const softTargetMs = (route) => {
84
+ if (mode === 'research')
85
+ return 90_000;
86
+ return agentRouteDeadlineMs(route) ?? 15_000;
87
+ };
88
+ // Narration must still be reachable after a full generation window, and must
89
+ // leave the hard deadline (45s ask / 120s research) room to land.
90
+ const narrationSoftTargetMs = () => (mode === 'research' ? 100_000 : 38_000);
91
+ return Object.freeze({
92
+ startedAtMs,
93
+ hardDeadlineMs,
94
+ hardSignal,
95
+ mode,
96
+ elapsedMs,
97
+ remainingMs: () => Math.max(0, hardDeadlineMs - elapsedMs()),
98
+ softTargetMs,
99
+ mayStartDiscovery: (route) => !hardSignal.aborted && elapsedMs() < softTargetMs(route),
100
+ narrationSoftTargetMs,
101
+ mayStartNarration: () => !hardSignal.aborted && elapsedMs() < narrationSoftTargetMs(),
102
+ });
103
+ }
39
104
  /**
40
105
  * Routes whose gate failure is better answered by switching routes than by
41
106
  * re-running the same executor (repair can't add what the route can't produce).
@@ -250,7 +315,7 @@ export function escalationRouteFor(route, audience) {
250
315
  if (!target)
251
316
  return undefined;
252
317
  if (audience === "stakeholder" && ANALYST_ONLY_ROUTES.has(target))
253
- return "research";
318
+ return "generated_answer";
254
319
  return target;
255
320
  }
256
321
  /** The handoff action shown on a stakeholder's review-required output. */
@@ -369,6 +434,7 @@ export class AgentRunEngine {
369
434
  now;
370
435
  budgetModel;
371
436
  maxSteps;
437
+ routeTimeoutSignal;
372
438
  constructor(options = {}) {
373
439
  this.executors = options.executors ?? {};
374
440
  this.gates = options.gates ?? {};
@@ -386,6 +452,7 @@ export class AgentRunEngine {
386
452
  engineEscalations: options.maxEngineEscalations ?? options.budgets?.engineEscalations,
387
453
  };
388
454
  this.maxSteps = Math.max(1, options.maxSteps ?? DEFAULT_MAX_STEPS);
455
+ this.routeTimeoutSignal = options.routeTimeoutSignal ?? ((durationMs) => AbortSignal.timeout(durationMs));
389
456
  }
390
457
  /**
391
458
  * Decide the high-level action. A forced `requestedMode` bypasses routing.
@@ -406,7 +473,8 @@ export class AgentRunEngine {
406
473
  if (request.requestedMode === "ask"
407
474
  && routed.action !== "converse"
408
475
  && routed.action !== "compose_app"
409
- && routed.requiresClarification !== true) {
476
+ && routed.requiresClarification !== true
477
+ && !routed.terminalOutcome) {
410
478
  return { ...routed, action: "answer" };
411
479
  }
412
480
  return routed;
@@ -440,7 +508,16 @@ export class AgentRunEngine {
440
508
  }
441
509
  const runId = request.runId ?? this.idGenerator();
442
510
  const startedAt = this.timestamp();
511
+ const runStartedAtMs = Date.parse(startedAt);
443
512
  const requestedMode = request.requestedMode ?? "auto";
513
+ const runBudget = request.runBudget ?? createAgentRunBudget({
514
+ requestedMode,
515
+ startedAtMs: runStartedAtMs,
516
+ inheritedSignal: request.signal,
517
+ timeoutSignal: this.routeTimeoutSignal,
518
+ nowMs: () => this.now().getTime(),
519
+ });
520
+ request = { ...request, runBudget, signal: runBudget.hardSignal };
444
521
  const events = [];
445
522
  let plan;
446
523
  const executedSteps = [];
@@ -550,15 +627,22 @@ export class AgentRunEngine {
550
627
  followsUp: true,
551
628
  source: "heuristic",
552
629
  }
553
- : await this.decideRoute(request);
630
+ : await awaitWithAbort(this.decideRoute(request), request.signal);
631
+ routeDecision = enforceOrdinaryAnalyticalPlanBoundary(request, routeDecision);
554
632
  const defaultRoute = answerAnywayRoute(constrainRouteForAudience(selectRoute(request, routeDecision), audience), request, audience, routeDecision);
555
- plan = await this.planner.plan({
633
+ const authoritativeAsk = routeDecision.resolvedAnalyticalPlan?.mode === 'authoritative'
634
+ && requestedMode !== 'research';
635
+ const activePlanner = authoritativeAsk
636
+ ? createDeterministicAgentRunPlanner()
637
+ : this.planner;
638
+ const planningSignal = request.runBudget?.hardSignal ?? request.signal;
639
+ plan = await awaitWithAbort(Promise.resolve(activePlanner.plan({
556
640
  request,
557
641
  routeDecision,
558
642
  defaultRoute,
559
643
  maxSteps: this.maxSteps,
560
644
  audience,
561
- });
645
+ })), planningSignal);
562
646
  emit({
563
647
  type: "plan.created",
564
648
  message: plan.rationale,
@@ -643,6 +727,11 @@ export class AgentRunEngine {
643
727
  emitAnswerDelta: onAnswerDelta,
644
728
  });
645
729
  result = consumeRepeatedClarificationSelection(request, routeDecision, result);
730
+ if (result.analyticalTurnPlan)
731
+ progress.analyticalTurnPlan = result.analyticalTurnPlan;
732
+ if (result.analyticalTaskOutcomes)
733
+ progress.analyticalTaskOutcomes = result.analyticalTaskOutcomes;
734
+ persistProgress();
646
735
  evaluations = this.evaluate({ route, request, routeDecision, result, attempt });
647
736
  for (const evaluation of evaluations) {
648
737
  emit({
@@ -662,6 +751,13 @@ export class AgentRunEngine {
662
751
  stepStatus = attempt > 0 ? "repaired" : "passed";
663
752
  break;
664
753
  }
754
+ // A frozen analytical plan has one route and no downstream planner,
755
+ // rematch, route escalation, or whole-answer regeneration authority.
756
+ // Typed server-issued repair is a separate derived run.
757
+ if (authoritativeAsk) {
758
+ stepStatus = 'needs_review';
759
+ break;
760
+ }
665
761
  const currentStep = {
666
762
  id: stepId,
667
763
  index: stepCount,
@@ -674,7 +770,7 @@ export class AgentRunEngine {
674
770
  evaluations,
675
771
  artifacts: result.artifacts ?? [],
676
772
  };
677
- const decision = await this.planner.replan({
773
+ const decision = await activePlanner.replan({
678
774
  request,
679
775
  plan,
680
776
  currentStep,
@@ -779,7 +875,7 @@ export class AgentRunEngine {
779
875
  });
780
876
  continue;
781
877
  }
782
- const outcome = computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion);
878
+ const outcome = computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion, routeDecision.terminalOutcome?.message);
783
879
  const step = {
784
880
  id: stepId,
785
881
  index: stepCount,
@@ -862,15 +958,102 @@ export class AgentRunEngine {
862
958
  run.completedAt = this.timestamp();
863
959
  run.lifecycle = terminalLifecycle(progress.lifecycle, run.status === "blocked" ? "run.failed" : "run.completed", run.completedAt, events.length);
864
960
  run.diagnosticReceipt = diagnosticReceiptForRun(run);
865
- run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt);
961
+ run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
962
+ run.artifacts = attachDiagnosticReceipt(run.artifacts, run.diagnosticReceipt, run.diagnosticReceiptV2);
866
963
  await checkpointQueue;
867
964
  await this.store?.save(run);
868
965
  return run;
869
966
  }
870
967
  catch (err) {
871
- const message = err instanceof Error && err.name === "TimeoutError"
872
- ? "This analytical run reached its time limit before it finished. A timeout alone does not prove a cross-model join or semantic-modeling problem. Open Trust & Steps to see the last recorded phase; retry the same bounded question or use Research for a longer budget. No result was accepted."
873
- : err instanceof Error ? err.message : String(err);
968
+ const dispatchEvidence = providerDispatchEvidenceFromError(err);
969
+ const userCancelled = isAgentRunUserCancellation(request.signal?.reason) || isAgentRunUserCancellation(err);
970
+ if (userCancelled) {
971
+ const message = "Stopped by user.";
972
+ const cancelledRoute = progress.route ?? "cancelled";
973
+ const cancelledPhase = progress.lifecycle.phase;
974
+ emit({
975
+ type: "run.cancelled",
976
+ message,
977
+ route: cancelledRoute,
978
+ status: "cancelled",
979
+ trustState: "not_applicable",
980
+ });
981
+ const completedAt = this.timestamp();
982
+ const failure = {
983
+ code: AGENT_RUN_USER_CANCEL_CODE,
984
+ phase: cancelledPhase,
985
+ message,
986
+ recoverable: false,
987
+ safeActions: [],
988
+ };
989
+ const evaluations = [
990
+ ...progress.evaluations,
991
+ {
992
+ id: "run-cancelled",
993
+ label: "Run cancelled",
994
+ passed: true,
995
+ severity: "info",
996
+ message,
997
+ },
998
+ ];
999
+ const receipt = {
1000
+ version: 1,
1001
+ runId,
1002
+ phase: failure.phase,
1003
+ route: cancelledRoute,
1004
+ plan,
1005
+ steps: executedSteps,
1006
+ artifacts: progress.artifacts,
1007
+ evaluations,
1008
+ failure,
1009
+ };
1010
+ const run = {
1011
+ id: runId,
1012
+ question: submittedQuestion,
1013
+ requestedMode,
1014
+ route: cancelledRoute,
1015
+ status: "cancelled",
1016
+ trustState: "not_applicable",
1017
+ stopReason: "cancelled",
1018
+ startedAt,
1019
+ completedAt,
1020
+ selectedObject: request.selectedObject,
1021
+ executionTarget: request.executionTarget,
1022
+ routeDecision,
1023
+ plan,
1024
+ steps: executedSteps,
1025
+ summary: message,
1026
+ artifacts: progress.artifacts,
1027
+ evaluations,
1028
+ events,
1029
+ nextActions: [],
1030
+ repairAttempts: 0,
1031
+ escalationAttempts: 0,
1032
+ budgetUsage: cascadeBudgetTrace(createCascadeBudgetState(this.budgetModel)),
1033
+ diagnosticReceipt: receipt,
1034
+ ...(dispatchEvidence.providerEgressReceipts.length
1035
+ ? { providerEgressReceipts: dispatchEvidence.providerEgressReceipts }
1036
+ : {}),
1037
+ telemetry: {
1038
+ ...emptyRunTelemetry(durationBetweenMs(startedAt, completedAt), "cancelled"),
1039
+ providerRoundTrips: dispatchEvidence.providerRoundTrips,
1040
+ toolCalls: dispatchEvidence.toolCalls,
1041
+ sqlExecutions: dispatchEvidence.sqlExecutions,
1042
+ repairs: dispatchEvidence.repairs,
1043
+ egressReceipts: dispatchEvidence.providerEgressReceipts.length,
1044
+ },
1045
+ lifecycle: terminalLifecycle(progress.lifecycle, "run.cancelled", completedAt, events.length),
1046
+ };
1047
+ run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1048
+ await checkpointQueue;
1049
+ await this.store?.save(run);
1050
+ return run;
1051
+ }
1052
+ const message = isOrchestrationBudgetExhausted(err)
1053
+ ? 'Ask could not complete within its bounded orchestration. Nothing was executed; narrow the metric or dimension and retry.'
1054
+ : err instanceof Error && err.name === "TimeoutError"
1055
+ ? "This analytical run reached its time limit before it finished. A timeout alone does not prove a cross-model join or semantic-modeling problem. Open Trust & Steps to see the last recorded phase; retry the same bounded question or use Research for a longer budget. No result was accepted."
1056
+ : err instanceof Error ? err.message : String(err);
874
1057
  const failedRoute = progress.route;
875
1058
  const failedPhase = progress.lifecycle.phase;
876
1059
  emit({
@@ -931,8 +1114,21 @@ export class AgentRunEngine {
931
1114
  escalationAttempts: 0,
932
1115
  budgetUsage: cascadeBudgetTrace(createCascadeBudgetState(this.budgetModel)),
933
1116
  diagnosticReceipt: receipt,
1117
+ ...(dispatchEvidence.providerEgressReceipts.length
1118
+ ? { providerEgressReceipts: dispatchEvidence.providerEgressReceipts }
1119
+ : {}),
1120
+ telemetry: {
1121
+ ...emptyRunTelemetry(durationBetweenMs(startedAt, completedAt), dispatchEvidence.fallbackReason),
1122
+ providerRoundTrips: dispatchEvidence.providerRoundTrips,
1123
+ toolCalls: dispatchEvidence.toolCalls,
1124
+ sqlExecutions: dispatchEvidence.sqlExecutions,
1125
+ repairs: dispatchEvidence.repairs,
1126
+ egressReceipts: dispatchEvidence.providerEgressReceipts.length,
1127
+ },
934
1128
  lifecycle: terminalLifecycle(progress.lifecycle, "run.failed", completedAt, events.length),
935
1129
  };
1130
+ run.diagnosticReceiptV2 = diagnosticReceiptV2ForRun(run);
1131
+ run.artifacts = attachDiagnosticReceipt(retainedArtifacts, receipt, run.diagnosticReceiptV2);
936
1132
  await checkpointQueue;
937
1133
  await this.store?.save(run);
938
1134
  return run;
@@ -985,8 +1181,12 @@ export class AgentRunEngine {
985
1181
  // If the final step produced no user-facing answer (e.g. it only drafted an
986
1182
  // artifact), fall back to the last step that DID answer so the run never
987
1183
  // drops a data answer an earlier step already computed.
988
- const finalHasAnswer = typeof finalResult.answer === "string" && finalResult.answer.trim().length > 0;
1184
+ const finalHasAnswer = finalOutcome.status !== "blocked"
1185
+ && typeof finalResult.answer === "string" && finalResult.answer.trim().length > 0;
989
1186
  const answerSource = finalHasAnswer ? finalResult : (input.bestAnswerResult ?? finalResult);
1187
+ const acceptedAnswer = finalOutcome.status === "blocked"
1188
+ ? finalOutcome.summary
1189
+ : input.clarifyOutcome?.question ?? answerSource.answer;
990
1190
  return {
991
1191
  id: input.runId,
992
1192
  question: input.request.question,
@@ -1003,7 +1203,7 @@ export class AgentRunEngine {
1003
1203
  plan: input.plan,
1004
1204
  steps: input.steps,
1005
1205
  summary: finalOutcome.summary,
1006
- answer: input.clarifyOutcome?.question ?? answerSource.answer,
1206
+ answer: acceptedAnswer,
1007
1207
  answerKind: answerSource.answerKind ?? "governed",
1008
1208
  artifacts,
1009
1209
  evaluations: finalStep.evaluations,
@@ -1014,8 +1214,16 @@ export class AgentRunEngine {
1014
1214
  ? { clarificationOptions: finalResult.clarificationOptions ?? input.routeDecision.clarificationOptions }
1015
1215
  : {}),
1016
1216
  repairAttempts: finalResult.repairAttempts ?? repairAttempts,
1217
+ ...(finalResult.providerEgressReceipts?.length
1218
+ ? { providerEgressReceipts: finalResult.providerEgressReceipts }
1219
+ : {}),
1220
+ ...(finalResult.telemetry ? {
1221
+ telemetry: withTotalDuration(finalResult.telemetry, durationBetweenMs(input.startedAt, completedAt)),
1222
+ } : {}),
1017
1223
  escalationAttempts,
1018
1224
  budgetUsage: input.budgetUsage,
1225
+ ...(finalResult.analyticalTurnPlan ? { analyticalTurnPlan: finalResult.analyticalTurnPlan } : {}),
1226
+ ...(finalResult.analyticalTaskOutcomes ? { analyticalTaskOutcomes: finalResult.analyticalTaskOutcomes } : {}),
1019
1227
  ...authoringDerivationFromRequest(input.request),
1020
1228
  };
1021
1229
  }
@@ -1028,14 +1236,229 @@ export class AgentRunEngine {
1028
1236
  }
1029
1237
  async executeRoute(context) {
1030
1238
  const executor = this.executors[context.route];
1031
- if (executor)
1032
- return executor(context);
1239
+ if (executor) {
1240
+ const frozenPlan = context.routeDecision?.resolvedAnalyticalPlan?.mode === 'authoritative';
1241
+ const startsAnalyticalWork = context.route === 'certified_answer'
1242
+ || context.route === 'semantic_answer'
1243
+ || context.route === 'generated_answer'
1244
+ || context.route === 'research';
1245
+ if (startsAnalyticalWork
1246
+ && !frozenPlan
1247
+ && context.request.runBudget
1248
+ && !context.request.runBudget.mayStartDiscovery(context.route)) {
1249
+ return softBoundaryResult(context.route, context.request.runBudget);
1250
+ }
1251
+ const signal = context.request.runBudget?.hardSignal ?? context.request.signal;
1252
+ if (signal?.aborted)
1253
+ throw signal.reason ?? routeTimeoutError();
1254
+ const execution = Promise.resolve(executor({
1255
+ ...context,
1256
+ request: { ...context.request, ...(signal ? { signal } : {}) },
1257
+ }));
1258
+ return awaitWithAbort(execution, signal);
1259
+ }
1033
1260
  return defaultExecutorResult(context.route, context.request, context.routeDecision);
1034
1261
  }
1262
+ absoluteDeadlineSignal(deadlineMs, runStartedAtMs, inherited) {
1263
+ const elapsedMs = Math.max(0, this.now().getTime() - runStartedAtMs);
1264
+ const remainingMs = deadlineMs - elapsedMs;
1265
+ const deadlineSignal = remainingMs <= 0
1266
+ ? alreadyAbortedSignal(routeTimeoutError())
1267
+ : this.routeTimeoutSignal(remainingMs);
1268
+ return inherited ? AbortSignal.any([inherited, deadlineSignal]) : deadlineSignal;
1269
+ }
1035
1270
  timestamp() {
1036
1271
  return this.now().toISOString();
1037
1272
  }
1038
1273
  }
1274
+ /**
1275
+ * Ordinary analytical Ask cannot delegate meaning to the legacy answer
1276
+ * generator. Retrieval/meaning must first produce the immutable RAP consumed by
1277
+ * every later compiler and executor. This host-side gate protects callers that
1278
+ * inject or deserialize an older router decision as well as the canonical
1279
+ * router producer.
1280
+ */
1281
+ function enforceOrdinaryAnalyticalPlanBoundary(request, decision) {
1282
+ const ordinaryAsk = request.requestedMode === undefined
1283
+ || request.requestedMode === 'auto'
1284
+ || request.requestedMode === 'ask';
1285
+ const terminal = decision.action === 'clarify'
1286
+ || decision.action === 'block'
1287
+ || decision.requiresClarification === true
1288
+ || Boolean(decision.terminalOutcome);
1289
+ // A modeling/coverage gap is a pre-freeze discovery result, not permission to
1290
+ // terminate an ordinary Ask. Keep the diagnostic as a typed reason in the
1291
+ // route decision, clear the blocked RAP, and let the answer executor continue
1292
+ // through the governed relational and review-required generated lanes. Policy
1293
+ // blocks and genuine user ambiguity remain terminal. This is the key
1294
+ // governed-first-but-not-governed-only boundary (AGT-028, EXP-001).
1295
+ if (ordinaryAsk
1296
+ && decision.terminalOutcome?.kind === 'modeling_gap'
1297
+ && decision.requiresClarification !== true
1298
+ && !request.selectedEvidenceId) {
1299
+ return {
1300
+ ...decision,
1301
+ action: 'answer',
1302
+ confidence: Math.min(decision.confidence, 0.55),
1303
+ reason: `${decision.terminalOutcome.message} Continuing through DBT-grounded relational and review-required generated analysis before asking for a modeling change.`,
1304
+ terminalOutcome: undefined,
1305
+ resolvedAnalyticalPlan: undefined,
1306
+ requiresClarification: false,
1307
+ };
1308
+ }
1309
+ const exactSemanticContinuation = Boolean(request.selectedEvidenceId
1310
+ && decision.meaningResolution?.recommendedRoute === 'semantic'
1311
+ && decision.meaningResolution.recommendedExecutionId === request.selectedEvidenceId);
1312
+ if (!ordinaryAsk
1313
+ || terminal
1314
+ || exactSemanticContinuation
1315
+ || decision.resolvedAnalyticalPlan?.mode === 'authoritative')
1316
+ return decision;
1317
+ const explicitAuthoring = looksLikeComposeApp(request.question)
1318
+ || /\b(sql\s+(?:notebook\s+)?cell|notebook\s+cell|write a select|generate a query|dql block|block draft|draft block|create[^.?!]*block|turn[^.?!]*into[^.?!]*block)\b/i.test(request.question);
1319
+ if (explicitAuthoring)
1320
+ return decision;
1321
+ const history = request.history?.length
1322
+ ? request.history
1323
+ : conversationHistoryFromContext(request.conversationContext);
1324
+ const questionPlan = buildAnalysisQuestionPlan(request.question);
1325
+ const conversationalKind = classifyConversationalTurn(request.question, history.length > 0 || Boolean(request.conversationContext && Object.keys(request.conversationContext).length > 0));
1326
+ const definitionOnly = questionPlan.mode === 'definition'
1327
+ && questionPlan.requestedShape.dimensions.length === 0
1328
+ && questionPlan.requestedShape.filters.length === 0
1329
+ && questionPlan.timeTerms.length === 0;
1330
+ // Router category/action are advisory and may be absent or forged. A
1331
+ // deterministic host parse owns the no-data lanes too, so a forged
1332
+ // `data_analysis` cannot turn "hi" into SQL and a forged `answer` cannot
1333
+ // turn a glossary definition into an analytical execution.
1334
+ if (ordinaryAsk && (conversationalKind || definitionOnly)) {
1335
+ return {
1336
+ ...decision,
1337
+ action: 'converse',
1338
+ category: 'conversational',
1339
+ ...(conversationalKind ? { conversationalKind } : {}),
1340
+ confidence: 1,
1341
+ reason: definitionOnly
1342
+ ? 'This requests a definition, not a warehouse result, so no analytical execution is needed.'
1343
+ : 'This is a conversational turn and does not request governed data.',
1344
+ requiresClarification: false,
1345
+ };
1346
+ }
1347
+ const analyticalIntents = new Set([
1348
+ 'exact_certified_lookup',
1349
+ 'ad_hoc_ranking',
1350
+ 'driver_breakdown',
1351
+ 'diagnose_change',
1352
+ 'segment_compare',
1353
+ 'entity_drilldown',
1354
+ 'anomaly_investigation',
1355
+ ]);
1356
+ const analyticalIntent = analyticalIntents.has(questionPlan.routeIntent)
1357
+ || Boolean(request.intent && analyticalIntents.has(request.intent));
1358
+ const analyticalShape = questionPlan.mode !== 'clarify'
1359
+ && questionPlan.mode !== 'definition'
1360
+ && (questionPlan.requestedShape.measures.length > 0
1361
+ || questionPlan.requestedShape.dimensions.length > 0
1362
+ || questionPlan.requestedShape.filters.length > 0
1363
+ || questionPlan.timeTerms.length > 0
1364
+ || questionPlan.requestedShape.rankingDirection !== undefined);
1365
+ const governedEvidence = (decision.retrievalEvidence?.candidateCount ?? 0) > 0
1366
+ || (decision.meaningResolution?.selectedConceptIds.length ?? 0) > 0;
1367
+ const analytical = analyticalIntent || analyticalShape || governedEvidence;
1368
+ if (!analytical)
1369
+ return decision;
1370
+ 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.';
1371
+ return {
1372
+ ...decision,
1373
+ action: 'block',
1374
+ confidence: 1,
1375
+ reason: message,
1376
+ requiresClarification: false,
1377
+ terminalOutcome: {
1378
+ kind: 'modeling_gap',
1379
+ code: 'ANALYTICAL_MODELING_GAP',
1380
+ message,
1381
+ candidateIds: decision.retrievalEvidence?.candidateIds ?? [],
1382
+ },
1383
+ };
1384
+ }
1385
+ function softBoundaryResult(route, budget) {
1386
+ const seconds = Math.round(budget.softTargetMs(route) / 1_000);
1387
+ return {
1388
+ resolvedRoute: 'clarify',
1389
+ status: 'needs_clarification',
1390
+ trustState: 'blocked',
1391
+ stopReason: 'needs_clarification',
1392
+ summary: `The ${seconds}-second discovery target elapsed before an analytical plan was frozen. No new provider, tool, or retrieval branch was started.`,
1393
+ answer: budget.mode === 'research'
1394
+ ? 'Research stopped starting new branches at 90 seconds. Refine the question or retry; any already validated partial findings remain available.'
1395
+ : 'The discovery window ended before DQL could freeze an exact analytical plan. Refine the metric or grain, or retry the same bounded question.',
1396
+ artifacts: [],
1397
+ evaluations: [],
1398
+ nextActions: [{ id: 'retry-after-soft-target', label: 'Retry the same question' }],
1399
+ };
1400
+ }
1401
+ function providerDispatchEvidenceFromError(error) {
1402
+ const empty = {
1403
+ providerEgressReceipts: [],
1404
+ providerRoundTrips: 0,
1405
+ toolCalls: 0,
1406
+ sqlExecutions: 0,
1407
+ repairs: 0,
1408
+ fallbackReason: 'executor_failure',
1409
+ };
1410
+ if (!error || typeof error !== 'object')
1411
+ return empty;
1412
+ const value = error.providerDispatchEvidence;
1413
+ if (!value || typeof value !== 'object')
1414
+ return empty;
1415
+ const record = value;
1416
+ const count = (key) => Number.isInteger(record[key]) && Number(record[key]) >= 0 ? Number(record[key]) : 0;
1417
+ return {
1418
+ providerEgressReceipts: Array.isArray(record.providerEgressReceipts)
1419
+ ? record.providerEgressReceipts.flatMap((receipt) => {
1420
+ const normalized = normalizeProviderEgressReceiptV1(receipt);
1421
+ return normalized ? [normalized] : [];
1422
+ })
1423
+ : [],
1424
+ providerRoundTrips: count('providerRoundTrips'),
1425
+ toolCalls: count('toolCalls'),
1426
+ sqlExecutions: count('sqlExecutions'),
1427
+ repairs: count('repairs'),
1428
+ fallbackReason: typeof record.fallbackReason === 'string' ? record.fallbackReason : 'executor_failure',
1429
+ };
1430
+ }
1431
+ function alreadyAbortedSignal(reason) {
1432
+ const controller = new AbortController();
1433
+ controller.abort(reason);
1434
+ return controller.signal;
1435
+ }
1436
+ function routeTimeoutError() {
1437
+ return new DOMException('The absolute agent route deadline elapsed.', 'TimeoutError');
1438
+ }
1439
+ function awaitWithAbort(work, signal) {
1440
+ if (!signal)
1441
+ return work;
1442
+ if (signal.aborted) {
1443
+ // The callee may already have started before its cancellation state was
1444
+ // observed. Consume any eventual rejection while refusing its result.
1445
+ void work.catch(() => undefined);
1446
+ return Promise.reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
1447
+ }
1448
+ return new Promise((resolve, reject) => {
1449
+ let settled = false;
1450
+ const finish = (callback) => {
1451
+ if (settled)
1452
+ return;
1453
+ settled = true;
1454
+ signal.removeEventListener('abort', onAbort);
1455
+ callback();
1456
+ };
1457
+ const onAbort = () => finish(() => reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')));
1458
+ signal.addEventListener('abort', onAbort, { once: true });
1459
+ work.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
1460
+ });
1461
+ }
1039
1462
  function authoringDerivationFromRequest(request) {
1040
1463
  if (request.requestedMode !== 'modeling' && request.requestedMode !== 'skill')
1041
1464
  return {};
@@ -1073,6 +1496,15 @@ function diagnosticFailureFromError(error, phase) {
1073
1496
  const name = error instanceof Error ? error.name : "";
1074
1497
  const message = error instanceof Error ? error.message : String(error);
1075
1498
  const lower = `${name} ${message}`.toLowerCase();
1499
+ if (isOrchestrationBudgetExhausted(error)) {
1500
+ return {
1501
+ code: 'orchestration_budget_exhausted',
1502
+ phase,
1503
+ message: 'Ask exhausted its bounded orchestration before a final answer was available.',
1504
+ recoverable: false,
1505
+ safeActions: ['inspect_failure'],
1506
+ };
1507
+ }
1076
1508
  if (name === "TimeoutError" || lower.includes("time limit") || lower.includes("timeout")) {
1077
1509
  return {
1078
1510
  code: "TIMEOUT",
@@ -1082,13 +1514,13 @@ function diagnosticFailureFromError(error, phase) {
1082
1514
  safeActions: ["retry_same_plan"],
1083
1515
  };
1084
1516
  }
1085
- if (lower.includes("stopped by user") || lower.includes("cancel")) {
1517
+ if (isAgentRunUserCancellation(error)) {
1086
1518
  return {
1087
1519
  code: "RUN_CANCELLED",
1088
1520
  phase,
1089
- message,
1090
- recoverable: true,
1091
- safeActions: ["retry_same_request"],
1521
+ message: "Stopped by user.",
1522
+ recoverable: false,
1523
+ safeActions: [],
1092
1524
  };
1093
1525
  }
1094
1526
  return {
@@ -1099,6 +1531,14 @@ function diagnosticFailureFromError(error, phase) {
1099
1531
  safeActions: ["retry_same_request", "inspect_failure"],
1100
1532
  };
1101
1533
  }
1534
+ function isOrchestrationBudgetExhausted(error) {
1535
+ if (!error || typeof error !== 'object')
1536
+ return false;
1537
+ if (error.code === 'PROVIDER_DISPATCH_BUDGET_EXHAUSTED')
1538
+ return true;
1539
+ const message = error instanceof Error ? error.message : String(error);
1540
+ return /(?:run-wide provider dispatch|orchestration) budget exhausted/i.test(message);
1541
+ }
1102
1542
  function diagnosticReceiptForRun(run) {
1103
1543
  const failureEvaluation = run.evaluations.find((evaluation) => !evaluation.passed && evaluation.severity === "blocking");
1104
1544
  const analyticalFailure = run.artifacts
@@ -1118,7 +1558,7 @@ function diagnosticReceiptForRun(run) {
1118
1558
  return {
1119
1559
  version: 1,
1120
1560
  runId: run.id,
1121
- phase: run.lifecycle?.phase ?? (run.status === "blocked" ? "run.failed" : "run.completed"),
1561
+ phase: run.lifecycle?.phase ?? (run.status === "blocked" ? "run.failed" : run.status === "cancelled" ? "run.cancelled" : "run.completed"),
1122
1562
  route: run.route,
1123
1563
  ...(run.routeDecision?.resolvedAnalyticalPlan
1124
1564
  ? { resolvedAnalyticalPlan: run.routeDecision.resolvedAnalyticalPlan }
@@ -1127,20 +1567,59 @@ function diagnosticReceiptForRun(run) {
1127
1567
  steps: run.steps,
1128
1568
  artifacts: run.artifacts,
1129
1569
  evaluations: run.evaluations,
1130
- ...(run.status === "blocked"
1570
+ ...(run.repairCapability ? { repairCapability: run.repairCapability } : {}),
1571
+ ...(run.providerEgressReceipts?.length ? { providerEgressReceipts: run.providerEgressReceipts } : {}),
1572
+ ...(run.status === "blocked" || run.status === "cancelled"
1131
1573
  ? {
1132
1574
  failure: {
1133
- code: failureCode,
1134
- phase: run.lifecycle?.phase ?? "run.failed",
1135
- message: failureEvaluation?.message ?? run.summary,
1136
- recoverable: Boolean(run.nextActions.length),
1137
- safeActions: run.nextActions.map((action) => action.id),
1575
+ code: run.status === "cancelled" ? AGENT_RUN_USER_CANCEL_CODE : failureCode,
1576
+ phase: run.lifecycle?.phase ?? (run.status === "cancelled" ? "run.cancelled" : "run.failed"),
1577
+ message: run.status === "cancelled" ? "Stopped by user." : failureEvaluation?.message ?? run.summary,
1578
+ recoverable: run.status === "cancelled" ? false : Boolean(run.nextActions.length),
1579
+ safeActions: run.status === "cancelled" ? [] : run.nextActions.map((action) => action.id),
1138
1580
  },
1139
1581
  }
1140
1582
  : {}),
1141
1583
  };
1142
1584
  }
1143
- function attachDiagnosticReceipt(artifacts, receipt) {
1585
+ function diagnosticReceiptV2ForRun(run) {
1586
+ const telemetry = run.telemetry ?? emptyRunTelemetry(durationBetweenMs(run.startedAt, run.completedAt), 'not_recorded');
1587
+ return {
1588
+ version: 2,
1589
+ runId: run.id,
1590
+ route: run.route,
1591
+ status: run.status,
1592
+ telemetry,
1593
+ providerEgressReceiptFingerprints: (run.providerEgressReceipts ?? []).map(receiptFingerprint),
1594
+ ...(run.repairCapability ? { repairCapabilityFingerprint: receiptFingerprint(run.repairCapability) } : {}),
1595
+ };
1596
+ }
1597
+ function emptyRunTelemetry(total, fallbackReason) {
1598
+ return {
1599
+ version: 1,
1600
+ stageDurationsMs: { total },
1601
+ providerRoundTrips: 0,
1602
+ toolCalls: 0,
1603
+ sqlExecutions: 0,
1604
+ repairs: 0,
1605
+ egressReceipts: 0,
1606
+ fallbackReason,
1607
+ };
1608
+ }
1609
+ function withTotalDuration(telemetry, total) {
1610
+ return {
1611
+ ...telemetry,
1612
+ stageDurationsMs: { ...telemetry.stageDurationsMs, total },
1613
+ };
1614
+ }
1615
+ function durationBetweenMs(startedAt, completedAt) {
1616
+ const duration = Date.parse(completedAt) - Date.parse(startedAt);
1617
+ return Number.isFinite(duration) && duration >= 0 ? Math.min(86_400_000, duration) : 0;
1618
+ }
1619
+ function receiptFingerprint(value) {
1620
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
1621
+ }
1622
+ function attachDiagnosticReceipt(artifacts, receipt, receiptV2) {
1144
1623
  if (artifacts.length === 0) {
1145
1624
  if (!receipt.failure)
1146
1625
  return artifacts;
@@ -1149,7 +1628,7 @@ function attachDiagnosticReceipt(artifacts, receipt) {
1149
1628
  kind: "answer",
1150
1629
  title: "Agent run diagnostics",
1151
1630
  trustState: "blocked",
1152
- payload: { diagnosticReceipt: receipt },
1631
+ payload: { diagnosticReceipt: receipt, ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}) },
1153
1632
  }];
1154
1633
  }
1155
1634
  const preferredIndex = Math.max(0, artifacts.findIndex((artifact) => artifact.kind === "answer"));
@@ -1164,11 +1643,12 @@ function attachDiagnosticReceipt(artifacts, receipt) {
1164
1643
  payload: {
1165
1644
  ...payload,
1166
1645
  diagnosticReceipt: receipt,
1646
+ ...(receiptV2 ? { diagnosticReceiptV2: receiptV2 } : {}),
1167
1647
  },
1168
1648
  };
1169
1649
  });
1170
1650
  }
1171
- function computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion) {
1651
+ function computeStepOutcome(route, result, evaluations, request, isClarify, clarifyQuestion, terminalOutcomeMessage) {
1172
1652
  const fallback = defaultOutcome(route);
1173
1653
  if (isClarify) {
1174
1654
  return {
@@ -1195,10 +1675,21 @@ function computeStepOutcome(route, result, evaluations, request, isClarify, clar
1195
1675
  trustState,
1196
1676
  artifacts,
1197
1677
  stopReason,
1198
- summary: result.summary ?? fallback.summary,
1678
+ summary: status === "blocked"
1679
+ ? terminalOutcomeMessage ?? blockingOutcomeSummary(evaluations, fallback.summary)
1680
+ : result.summary ?? fallback.summary,
1199
1681
  ...(result.answerTier ? { terminalTier: result.answerTier } : {}),
1200
1682
  };
1201
1683
  }
1684
+ function blockingOutcomeSummary(evaluations, fallback) {
1685
+ const messages = evaluations
1686
+ .filter((evaluation) => !evaluation.passed && evaluation.severity === 'blocking')
1687
+ .map((evaluation) => evaluation.message.trim())
1688
+ .filter(Boolean);
1689
+ return messages[0] ?? (fallback.includes('Answered')
1690
+ ? 'The analytical result did not pass its required validation and was not accepted.'
1691
+ : fallback);
1692
+ }
1202
1693
  function consumeRepeatedClarificationSelection(request, routeDecision, result) {
1203
1694
  const selectedEvidenceId = request.selectedEvidenceId;
1204
1695
  if (!selectedEvidenceId || result.status !== 'needs_clarification')
@@ -1282,7 +1773,7 @@ export function createDeterministicAgentRunPlanner() {
1282
1773
  // Honor an explicit escalate target, else the route's default — then clamp for the audience.
1283
1774
  const rawEscalation = requested ?? AGENT_RUN_ESCALATION_MAP[currentStep.route];
1284
1775
  const escalationRoute = rawEscalation
1285
- ? (audience === "stakeholder" && ANALYST_ONLY_ROUTES.has(rawEscalation) ? "research" : rawEscalation)
1776
+ ? (audience === "stakeholder" && ANALYST_ONLY_ROUTES.has(rawEscalation) ? "generated_answer" : rawEscalation)
1286
1777
  : undefined;
1287
1778
  const hint = action?.hint ?? failing.suggestedRepair ?? "Revise and retry.";
1288
1779
  if (action?.kind === "escalate" && escalationRoute) {
@@ -1376,6 +1867,19 @@ function requestedModeToAction(mode) {
1376
1867
  export function selectRoute(request, decision) {
1377
1868
  if (decision.meaningResolutionErrorCode === 'invalid_evidence_reference')
1378
1869
  return 'blocked';
1870
+ if (decision.action === 'block')
1871
+ return 'blocked';
1872
+ const authoritativePlan = decision.resolvedAnalyticalPlan?.mode === 'authoritative'
1873
+ ? decision.resolvedAnalyticalPlan
1874
+ : undefined;
1875
+ // Producer invariant: the immutable plan is the authority. A caller cannot
1876
+ // combine an answer decision with blocked capability and suppress both the
1877
+ // typed clarification and modeling/policy diagnostic.
1878
+ if (authoritativePlan?.capability === 'blocked') {
1879
+ if (decision.requiresClarification === true)
1880
+ return 'clarify';
1881
+ return 'blocked';
1882
+ }
1379
1883
  const explicitMode = request.requestedMode;
1380
1884
  if (explicitMode === 'modeling')
1381
1885
  return 'modeling_draft';
@@ -1384,9 +1888,21 @@ export function selectRoute(request, decision) {
1384
1888
  if (explicitMode && explicitMode !== 'auto' && explicitMode !== 'ask') {
1385
1889
  return selectCascadeRunRoute(request, decision);
1386
1890
  }
1891
+ const plan = decision.resolvedAnalyticalPlan;
1892
+ // Research is an explicit request mode, never a phrase/category/depth
1893
+ // inference. Diagnosis and bounded exploration in ordinary Ask stay on the
1894
+ // review-required generated route over the same frozen plan.
1895
+ if (decision.action === 'investigate') {
1896
+ if (plan?.capability === 'certified_execution')
1897
+ return 'certified_answer';
1898
+ if (plan?.capability === 'semantic_execution')
1899
+ return 'semantic_answer';
1900
+ if (plan?.capability === 'governed_relational' || plan?.capability === 'bounded_exploration')
1901
+ return 'generated_answer';
1902
+ return decision.requiresClarification ? 'clarify' : 'blocked';
1903
+ }
1387
1904
  if (decision.action !== 'answer')
1388
1905
  return selectCascadeRunRoute(request, decision);
1389
- const plan = decision.resolvedAnalyticalPlan;
1390
1906
  if (plan?.mode === 'authoritative' && decision.requiresClarification !== true) {
1391
1907
  if (plan.capability === 'certified_execution')
1392
1908
  return 'certified_answer';
@@ -1395,7 +1911,7 @@ export function selectRoute(request, decision) {
1395
1911
  if (plan.capability === 'governed_relational')
1396
1912
  return 'generated_answer';
1397
1913
  if (plan.capability === 'bounded_exploration')
1398
- return 'research';
1914
+ return 'generated_answer';
1399
1915
  return 'blocked';
1400
1916
  }
1401
1917
  // Retrieval + meaning resolution already established a compatible execution
@@ -1412,8 +1928,9 @@ export function selectRoute(request, decision) {
1412
1928
  }
1413
1929
  function defaultExecutorResult(route, request, decision) {
1414
1930
  const fallback = defaultOutcome(route);
1931
+ const terminalMessage = route === 'blocked' ? decision?.terminalOutcome?.message : undefined;
1415
1932
  return {
1416
- summary: fallback.summary,
1933
+ summary: terminalMessage ?? fallback.summary,
1417
1934
  answer: route === "clarify" ? decision?.clarifyingQuestion : undefined,
1418
1935
  evaluations: defaultEvaluations(route, request, decision),
1419
1936
  artifacts: defaultArtifacts(route, {}, request),
@@ -1594,6 +2111,8 @@ function defaultArtifacts(route, result, request) {
1594
2111
  }
1595
2112
  }
1596
2113
  function stopReasonFor(route, status, trustState, artifacts) {
2114
+ if (status === "cancelled")
2115
+ return "cancelled";
1597
2116
  if (status === "blocked" || trustState === "blocked")
1598
2117
  return "blocked";
1599
2118
  if (route === "conversation")
@@ -1611,7 +2130,7 @@ function stopReasonFor(route, status, trustState, artifacts) {
1611
2130
  return "generated_review_required";
1612
2131
  }
1613
2132
  function defaultNextActions(route, status) {
1614
- if (status === "blocked")
2133
+ if (status === "blocked" || status === "cancelled")
1615
2134
  return [];
1616
2135
  if (route === "certified_answer") {
1617
2136
  return [