pi-smart-router 0.3.0 → 0.4.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 (37) hide show
  1. package/.pi/extensions/smart-router/index.ts +9 -0
  2. package/.pi/extensions/smart-router/pi-model-scope.ts +127 -20
  3. package/.pi/extensions/smart-router/planning-delegate.ts +318 -0
  4. package/.pi/extensions/smart-router/route-and-delegate.ts +21 -1
  5. package/.pi/extensions/smart-router/types.ts +3 -0
  6. package/README.md +32 -0
  7. package/dist/config/defaults.d.ts +2 -2
  8. package/dist/config/defaults.d.ts.map +1 -1
  9. package/dist/config/defaults.js +5 -3
  10. package/dist/config/defaults.js.map +1 -1
  11. package/dist/domain/pipeline/router-pipeline.d.ts +13 -1
  12. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  13. package/dist/domain/pipeline/router-pipeline.js +96 -18
  14. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  15. package/dist/domain/types/entities.d.ts +48 -0
  16. package/dist/domain/types/entities.d.ts.map +1 -1
  17. package/dist/domain/types/index.d.ts +1 -1
  18. package/dist/domain/types/index.d.ts.map +1 -1
  19. package/dist/domain/types/schemas.d.ts +29 -0
  20. package/dist/domain/types/schemas.d.ts.map +1 -1
  21. package/dist/domain/types/schemas.js +57 -0
  22. package/dist/domain/types/schemas.js.map +1 -1
  23. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
  24. package/dist/infrastructure/persistence/sqlite-store.js +2 -1
  25. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
  26. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +20 -1
  27. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  28. package/dist/infrastructure/telemetry/routing-telemetry.js +78 -2
  29. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/config/defaults.ts +6 -2
  32. package/src/domain/pipeline/router-pipeline.ts +141 -26
  33. package/src/domain/types/entities.ts +52 -0
  34. package/src/domain/types/index.ts +4 -0
  35. package/src/domain/types/schemas.ts +73 -0
  36. package/src/infrastructure/persistence/sqlite-store.ts +2 -0
  37. package/src/infrastructure/telemetry/routing-telemetry.ts +123 -7
@@ -11,6 +11,8 @@
11
11
 
12
12
  import type {
13
13
  ModelProfile,
14
+ PlanningDelegateConfig,
15
+ PlanningDelegateObservability,
14
16
  PriceCatalog,
15
17
  RoutingDecision,
16
18
  RoutingFeatureSidecar,
@@ -49,6 +51,10 @@ import {
49
51
  estimateRoutingCost,
50
52
  enrichRoutingDecisionWithContextFit,
51
53
  enrichRoutingDecisionWithTierSelection,
54
+ createPlanningDelegateObservability,
55
+ PLANNING_DELEGATE,
56
+ PLANNING_DELEGATE_DISABLED,
57
+ PLANNING_DIRECT_FRONTIER,
52
58
  } from '../../infrastructure/telemetry/routing-telemetry.js';
53
59
  import type { HydraMatcher as HydraMatcherType, MatchResult } from '../matching/hydra-matcher.js';
54
60
  import type { ClusterMatcher, ClusterMatchResult } from '../matching/cluster-matcher.js';
@@ -178,6 +184,8 @@ export interface PipelineOptions {
178
184
  readonly routingCalibrationPath?: string;
179
185
  /** SAAR pin policy (SP-123). Must match sessionPinner.saarConfig when enabled. */
180
186
  readonly saarConfig?: SaarConfig;
187
+ /** Planning delegate operator knobs (SP-143, #71). Defaults to operator config. */
188
+ readonly planningDelegateConfig?: PlanningDelegateConfig;
181
189
  }
182
190
 
183
191
  // ─── Orchestrator ────────────────────────────────────────────────────────────
@@ -217,6 +225,8 @@ export class RouterPipeline {
217
225
  private contextOverflowTriggered = false;
218
226
  /** Internal breakeven gate reason for SP-126 explain wiring. */
219
227
  private currentBreakevenReason: string | null = null;
228
+ /** Planning delegate observability for SP-143 explain/telemetry wiring. */
229
+ private currentPlanningDelegate: PlanningDelegateObservability | null = null;
220
230
 
221
231
  constructor(fleet: readonly ModelProfile[], options?: PipelineOptions) {
222
232
  this.fleet = fleet;
@@ -265,6 +275,7 @@ export class RouterPipeline {
265
275
  this.contextOverflowPreferredProvider = null;
266
276
  this.contextOverflowTriggered = false;
267
277
  this.currentBreakevenReason = null;
278
+ this.currentPlanningDelegate = null;
268
279
 
269
280
  let currentStage: NamedPipelineStage | undefined;
270
281
 
@@ -359,6 +370,9 @@ export class RouterPipeline {
359
370
  p_success_calibrated: this.currentPSuccessCalibrated,
360
371
  p_success_alpha: this.currentPSuccessAlpha,
361
372
  local_eligible_reason: this.currentLocalEligibleReason,
373
+ ...(this.currentPlanningDelegate
374
+ ? { planning_delegate: this.currentPlanningDelegate }
375
+ : {}),
362
376
  };
363
377
 
364
378
  const withBaseFeatures = { ...decision, features };
@@ -961,54 +975,155 @@ export class RouterPipeline {
961
975
  const tierCandidates = this.activeFleet.filter(
962
976
  (m) => m.tier === targetTier && m.healthy !== false,
963
977
  );
964
- const model = selectLowestCostModel(tierCandidates);
965
- if (!model) {
978
+ const targetModel = selectLowestCostModel(tierCandidates);
979
+ if (!targetModel) {
966
980
  return { decided: false, stage: 'turn_envelope' };
967
981
  }
968
982
 
969
983
  const pinner = this.options.sessionPinner;
970
984
  const pin = pinner?.getPin(request.session_id) ?? null;
971
- if (pin) {
972
- const pinnedModel = this.activeFleet.find(
973
- (m) => m.id === pin.pinned_model_id && m.healthy !== false,
985
+ const pinnedModel =
986
+ pin !== null
987
+ ? this.activeFleet.find(
988
+ (m) => m.id === pin.pinned_model_id && m.healthy !== false,
989
+ )
990
+ : undefined;
991
+
992
+ // SP-143: cache-preserving planning delegate when warm economical pin would switch to frontier.
993
+ if (
994
+ turnType === 'planning' &&
995
+ pinnedModel &&
996
+ pinnedModel.tier === 'economical-cloud' &&
997
+ pinnedModel.id !== targetModel.id
998
+ ) {
999
+ const delegateDecision = this.tryPlanningDelegateDecision(
1000
+ request,
1001
+ pinnedModel,
1002
+ targetModel,
974
1003
  );
975
- if (pinnedModel && pinnedModel.id !== model.id) {
976
- const skipBreakeven =
977
- turnType === 'planning' && this.isSaarPlanningBufferActive(request);
978
- if (!skipBreakeven) {
979
- const tokenEstimate =
980
- request.estimated_input_tokens ?? request.prompt_text.length;
981
- const breakeven = evaluateModelSwitchBreakeven(
982
- pinnedModel,
983
- model,
984
- tokenEstimate,
985
- tokenEstimate,
986
- this.options.saarConfig,
987
- );
988
- if (!breakeven.shouldSwitch) {
989
- this.currentBreakevenReason = 'breakeven_blocked';
990
- return { decided: false, stage: 'turn_envelope' };
991
- }
992
- this.currentBreakevenReason = 'breakeven_pass';
1004
+ if (delegateDecision) {
1005
+ return delegateDecision;
1006
+ }
1007
+ }
1008
+
1009
+ if (pinnedModel && pinnedModel.id !== targetModel.id) {
1010
+ const skipBreakeven =
1011
+ turnType === 'planning' && this.isSaarPlanningBufferActive(request);
1012
+ if (!skipBreakeven) {
1013
+ const tokenEstimate =
1014
+ request.estimated_input_tokens ?? request.prompt_text.length;
1015
+ const breakeven = evaluateModelSwitchBreakeven(
1016
+ pinnedModel,
1017
+ targetModel,
1018
+ tokenEstimate,
1019
+ tokenEstimate,
1020
+ this.options.saarConfig,
1021
+ );
1022
+ if (!breakeven.shouldSwitch) {
1023
+ this.currentBreakevenReason = 'breakeven_blocked';
1024
+ return { decided: false, stage: 'turn_envelope' };
993
1025
  }
1026
+ this.currentBreakevenReason = 'breakeven_pass';
994
1027
  }
995
1028
  }
996
1029
 
1030
+ const directReasonCode = `turn_${turnType}`;
1031
+ let planningDirectFallback: string | null = null;
1032
+
1033
+ if (
1034
+ turnType === 'planning' &&
1035
+ pinnedModel &&
1036
+ pinnedModel.tier === 'economical-cloud' &&
1037
+ pinnedModel.id !== targetModel.id
1038
+ ) {
1039
+ planningDirectFallback = this.resolvePlanningDirectFallbackReason();
1040
+ }
1041
+
1042
+ if (planningDirectFallback) {
1043
+ this.setPlanningDelegateDirectFallback(targetModel.id, planningDirectFallback);
1044
+ }
1045
+
997
1046
  return {
998
1047
  decided: true,
999
1048
  stage: 'turn_envelope',
1000
- decision: this.withEstimatedCost(request, model, {
1049
+ decision: this.withEstimatedCost(request, targetModel, {
1001
1050
  request_id: request.request_id,
1002
- selected_model_id: model.id,
1051
+ selected_model_id: targetModel.id,
1003
1052
  tier: targetTier,
1004
1053
  stage: 'turn_envelope',
1005
- reason_code: `turn_${turnType}`,
1054
+ reason_code: planningDirectFallback
1055
+ ? PLANNING_DIRECT_FRONTIER
1056
+ : directReasonCode,
1006
1057
  routing_latency_ms: 0,
1007
1058
  pin_reason: null,
1008
1059
  }),
1009
1060
  };
1010
1061
  }
1011
1062
 
1063
+ private resolvePlanningDirectFallbackReason(): string | null {
1064
+ const config = this.resolvePlanningDelegateConfig();
1065
+ if (!config.enabled) {
1066
+ return PLANNING_DELEGATE_DISABLED;
1067
+ }
1068
+ return null;
1069
+ }
1070
+
1071
+ private resolvePlanningDelegateConfig(): PlanningDelegateConfig {
1072
+ return (
1073
+ this.options.planningDelegateConfig ??
1074
+ DEFAULT_OPERATOR_CONFIG.planning_delegate
1075
+ );
1076
+ }
1077
+
1078
+ /**
1079
+ * SP-143: emit planning_delegate when enabled; otherwise record direct fallback
1080
+ * observability and return null so breakeven-gated direct frontier can proceed.
1081
+ */
1082
+ private tryPlanningDelegateDecision(
1083
+ request: RoutingRequest,
1084
+ pinnedModel: ModelProfile,
1085
+ frontierModel: ModelProfile,
1086
+ ): StageResult | null {
1087
+ const config = this.resolvePlanningDelegateConfig();
1088
+ if (!config.enabled) {
1089
+ return null;
1090
+ }
1091
+
1092
+ this.currentPlanningDelegate = createPlanningDelegateObservability({
1093
+ path: 'delegate',
1094
+ primary_model_id: pinnedModel.id,
1095
+ delegate_model_id: frontierModel.id,
1096
+ compressed_context: config.compressed_context,
1097
+ planning_delegate_reason_code: PLANNING_DELEGATE,
1098
+ });
1099
+
1100
+ return {
1101
+ decided: true,
1102
+ stage: 'turn_envelope',
1103
+ decision: this.withEstimatedCost(request, pinnedModel, {
1104
+ request_id: request.request_id,
1105
+ selected_model_id: pinnedModel.id,
1106
+ tier: pinnedModel.tier,
1107
+ stage: 'turn_envelope',
1108
+ reason_code: PLANNING_DELEGATE,
1109
+ routing_latency_ms: 0,
1110
+ pin_reason: null,
1111
+ }),
1112
+ };
1113
+ }
1114
+
1115
+ private setPlanningDelegateDirectFallback(
1116
+ delegateModelId: string | null,
1117
+ fallbackReason: string,
1118
+ ): void {
1119
+ this.currentPlanningDelegate = createPlanningDelegateObservability({
1120
+ path: 'direct',
1121
+ delegate_model_id: delegateModelId,
1122
+ planning_delegate_reason_code: PLANNING_DIRECT_FRONTIER,
1123
+ fallback_reason: fallbackReason,
1124
+ });
1125
+ }
1126
+
1012
1127
  // ─── Low-intensity tier gate (SP-103, #58) ───────────────────────────────
1013
1128
 
1014
1129
  /**
@@ -208,6 +208,48 @@ export interface BreakevenObservability {
208
208
  readonly breakeven_reason_code: string | null;
209
209
  }
210
210
 
211
+ /** Planning delegate routing path (SP-142, #71). */
212
+ export type PlanningDelegatePath = 'delegate' | 'direct' | 'none';
213
+
214
+ /**
215
+ * Compressed context limits for ephemeral frontier sub-calls (SP-142).
216
+ * Excludes full execution history per #71; enforced by pi extension in SP-144.
217
+ */
218
+ export interface CompressedContextSpec {
219
+ /** Max user/assistant messages retained in the delegate sub-call. */
220
+ readonly max_messages: number;
221
+ /** Max estimated tokens for delegate context window. */
222
+ readonly max_tokens: number;
223
+ /** When true, tool execution traces and verbose history are excluded. */
224
+ readonly exclude_execution_history: boolean;
225
+ }
226
+
227
+ /** Operator knobs for cache-preserving planning delegate (SP-142, #71). */
228
+ export interface PlanningDelegateConfig {
229
+ /** Prefer ephemeral frontier sub-call over primary model switch on planning turns. */
230
+ readonly enabled: boolean;
231
+ readonly compressed_context: CompressedContextSpec;
232
+ }
233
+
234
+ /** Planning delegate observability for explain and telemetry (SP-142, #71). */
235
+ export interface PlanningDelegateObservability {
236
+ /** Active routing path for this planning turn. */
237
+ readonly path: PlanningDelegatePath;
238
+ /** Primary model kept on pinned session when path is delegate. */
239
+ readonly primary_model_id: string | null;
240
+ /** Frontier model selected for delegate sub-call when path is delegate. */
241
+ readonly delegate_model_id: string | null;
242
+ /** Compressed context limits applied to delegate sub-call. */
243
+ readonly compressed_context: CompressedContextSpec | null;
244
+ /**
245
+ * Machine-readable path reason, e.g. `planning_delegate`, `planning_direct_frontier`,
246
+ * `planning_delegate_disabled`.
247
+ */
248
+ readonly planning_delegate_reason_code: string | null;
249
+ /** Operator-visible fallback explanation when path is not delegate. */
250
+ readonly fallback_reason: string | null;
251
+ }
252
+
211
253
  /** SAAR pin policy observability (SP-126, #72). */
212
254
  export interface SaarObservability {
213
255
  readonly buffer_active: boolean;
@@ -275,6 +317,8 @@ export interface RoutingFeatureSidecar {
275
317
  readonly breakeven?: BreakevenObservability;
276
318
  /** SAAR pin state summary (SP-126, #72). */
277
319
  readonly saar?: SaarObservability;
320
+ /** Planning delegate path summary (SP-142, #71). */
321
+ readonly planning_delegate?: PlanningDelegateObservability;
278
322
  /** Why local_zero eligibility passed (SP-111, #59). */
279
323
  readonly local_eligible_reason: string | null;
280
324
  }
@@ -441,4 +485,12 @@ export interface RoutingTelemetry {
441
485
  readonly saar_hard_lock: boolean;
442
486
  readonly turn_index_in_session: number | null;
443
487
  readonly saar_reason_code: string | null;
488
+ readonly planning_delegate_path: PlanningDelegatePath | null;
489
+ readonly planning_delegate_primary_model_id: string | null;
490
+ readonly planning_delegate_model_id: string | null;
491
+ readonly planning_delegate_reason_code: string | null;
492
+ readonly planning_delegate_fallback_reason: string | null;
493
+ readonly planning_delegate_max_messages: number | null;
494
+ readonly planning_delegate_max_tokens: number | null;
495
+ readonly planning_delegate_exclude_execution_history: boolean | null;
444
496
  }
@@ -2,12 +2,16 @@ export type {
2
2
  BreakevenObservability,
3
3
  CandidateScore,
4
4
  ClusterMatchTableEntry,
5
+ CompressedContextSpec,
5
6
  ContextFitObservability,
6
7
  ContextFitRejectedEntry,
7
8
  LoadedRoutingCluster,
8
9
  LowIntensityBreakdown,
9
10
  Message,
10
11
  ModelCapabilities,
12
+ PlanningDelegateConfig,
13
+ PlanningDelegateObservability,
14
+ PlanningDelegatePath,
11
15
  ModelLimits,
12
16
  ModelPerformance,
13
17
  ModelPricing,
@@ -251,6 +251,23 @@ export const SaarConfigSchema = z.object({
251
251
 
252
252
  export type SaarConfig = z.infer<typeof SaarConfigSchema>;
253
253
 
254
+ /** Compressed context limits for planning delegate sub-calls (SP-142, #71). */
255
+ export const CompressedContextSpecSchema = z.object({
256
+ max_messages: z.number().int().positive(),
257
+ max_tokens: z.number().int().positive(),
258
+ exclude_execution_history: z.boolean(),
259
+ });
260
+
261
+ export type CompressedContextSpec = z.infer<typeof CompressedContextSpecSchema>;
262
+
263
+ /** Planning delegate operator knobs (SP-142, #71). */
264
+ export const PlanningDelegateConfigSchema = z.object({
265
+ enabled: z.boolean(),
266
+ compressed_context: CompressedContextSpecSchema,
267
+ });
268
+
269
+ export type PlanningDelegateConfig = z.infer<typeof PlanningDelegateConfigSchema>;
270
+
254
271
  /** SAAR defaults per routing-roadmap.md §2 P0 (SP-121). */
255
272
  export const DEFAULT_SAAR_CONFIG: Readonly<SaarConfig> = {
256
273
  planning_turn_buffer: 2,
@@ -259,6 +276,61 @@ export const DEFAULT_SAAR_CONFIG: Readonly<SaarConfig> = {
259
276
  switch_threshold: 0.5,
260
277
  } as const;
261
278
 
279
+ /** Planning delegate defaults per routing-roadmap.md §2 P0 / #71 (SP-142). */
280
+ export const DEFAULT_PLANNING_DELEGATE_CONFIG: Readonly<PlanningDelegateConfig> = {
281
+ enabled: true,
282
+ compressed_context: {
283
+ max_messages: 12,
284
+ max_tokens: 16_384,
285
+ exclude_execution_history: true,
286
+ },
287
+ } as const;
288
+
289
+ /** Env: SMART_ROUTER_PLANNING_DELEGATE_ENABLED — enable delegate path (default true). */
290
+ const ENV_PLANNING_DELEGATE_ENABLED = 'SMART_ROUTER_PLANNING_DELEGATE_ENABLED';
291
+ /** Env: SMART_ROUTER_PLANNING_DELEGATE_MAX_MESSAGES — compressed context message cap (default 12). */
292
+ const ENV_PLANNING_DELEGATE_MAX_MESSAGES = 'SMART_ROUTER_PLANNING_DELEGATE_MAX_MESSAGES';
293
+ /** Env: SMART_ROUTER_PLANNING_DELEGATE_MAX_TOKENS — compressed context token cap (default 16384). */
294
+ const ENV_PLANNING_DELEGATE_MAX_TOKENS = 'SMART_ROUTER_PLANNING_DELEGATE_MAX_TOKENS';
295
+ /** Env: SMART_ROUTER_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY — exclude tool traces (default true). */
296
+ const ENV_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY =
297
+ 'SMART_ROUTER_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY';
298
+
299
+ function readBooleanEnv(name: string): boolean | undefined {
300
+ const raw = process.env[name];
301
+ if (raw === undefined || raw === '') {
302
+ return undefined;
303
+ }
304
+ const normalized = raw.trim().toLowerCase();
305
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes') {
306
+ return true;
307
+ }
308
+ if (normalized === '0' || normalized === 'false' || normalized === 'no') {
309
+ return false;
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ /** Merge planning delegate env overrides onto defaults (invalid env values are ignored). */
315
+ export function resolvePlanningDelegateConfigFromEnv(
316
+ base: PlanningDelegateConfig = DEFAULT_PLANNING_DELEGATE_CONFIG,
317
+ ): PlanningDelegateConfig {
318
+ return {
319
+ enabled: readBooleanEnv(ENV_PLANNING_DELEGATE_ENABLED) ?? base.enabled,
320
+ compressed_context: {
321
+ max_messages:
322
+ readPositiveIntEnv(ENV_PLANNING_DELEGATE_MAX_MESSAGES) ??
323
+ base.compressed_context.max_messages,
324
+ max_tokens:
325
+ readPositiveIntEnv(ENV_PLANNING_DELEGATE_MAX_TOKENS) ??
326
+ base.compressed_context.max_tokens,
327
+ exclude_execution_history:
328
+ readBooleanEnv(ENV_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY) ??
329
+ base.compressed_context.exclude_execution_history,
330
+ },
331
+ };
332
+ }
333
+
262
334
  /** Env: SMART_ROUTER_PLANNING_TURN_BUFFER — SAAR planning buffer turns (default 2). */
263
335
  const ENV_PLANNING_TURN_BUFFER = 'SMART_ROUTER_PLANNING_TURN_BUFFER';
264
336
  /** Env: SMART_ROUTER_PREFIX_CACHE_WEIGHT — SAAR prefix cache weight 0–1 (default 0.20). */
@@ -349,6 +421,7 @@ export const OperatorConfigSchema = z.object({
349
421
  hydra: HydraConfigSchema,
350
422
  low_intensity: LowIntensityConfigSchema,
351
423
  saar: SaarConfigSchema,
424
+ planning_delegate: PlanningDelegateConfigSchema,
352
425
  routing_clusters: RoutingClustersConfigSchema.optional(),
353
426
  });
354
427
 
@@ -24,6 +24,7 @@ import {
24
24
  } from '../telemetry/telemetry-limits.js';
25
25
  import {
26
26
  DEFAULT_BREAKEVEN_TELEMETRY_FIELDS,
27
+ DEFAULT_PLANNING_DELEGATE_TELEMETRY_FIELDS,
27
28
  DEFAULT_SAAR_TELEMETRY_FIELDS,
28
29
  } from '../telemetry/routing-telemetry.js';
29
30
  import {
@@ -833,6 +834,7 @@ function telemetryRowToEntity(row: TelemetryRow): RoutingTelemetry {
833
834
  tier_selection_reason_code: null,
834
835
  ...DEFAULT_BREAKEVEN_TELEMETRY_FIELDS,
835
836
  ...DEFAULT_SAAR_TELEMETRY_FIELDS,
837
+ ...DEFAULT_PLANNING_DELEGATE_TELEMETRY_FIELDS,
836
838
  };
837
839
  }
838
840
 
@@ -26,6 +26,8 @@ import type {
26
26
  ContextFitRejectedEntry,
27
27
  LowIntensityBreakdown,
28
28
  ModelProfile,
29
+ PlanningDelegateObservability,
30
+ PlanningDelegatePath,
29
31
  PriceCatalog,
30
32
  RejectedTierEntry,
31
33
  RoutingDecision,
@@ -59,6 +61,11 @@ export const BREAKEVEN_PASS = 'breakeven_pass' as const;
59
61
  export const SAAR_BUFFER_ACTIVE = 'saar_buffer_active' as const;
60
62
  export const SAAR_HARD_LOCK = 'saar_hard_lock' as const;
61
63
 
64
+ export const PLANNING_DELEGATE = 'planning_delegate' as const;
65
+ export const PLANNING_DIRECT_FRONTIER = 'planning_direct_frontier' as const;
66
+ export const PLANNING_DELEGATE_DISABLED = 'planning_delegate_disabled' as const;
67
+ export const PLANNING_DELEGATE_UNAVAILABLE = 'planning_delegate_unavailable' as const;
68
+
62
69
  const TURN_ENVELOPE_TIER_MAP: Readonly<Record<string, Tier | null>> = {
63
70
  planning: 'frontier-cloud',
64
71
  tool_result: 'economical-cloud',
@@ -538,6 +545,51 @@ function resolveSaarReasonCode(decision: RoutingDecision): string | null {
538
545
  return null;
539
546
  }
540
547
 
548
+ /** Build planning delegate observability from routing decision features (SP-142). */
549
+ export function buildPlanningDelegateObservability(
550
+ decision: RoutingDecision,
551
+ ): PlanningDelegateObservability | null {
552
+ return decision.features?.planning_delegate ?? null;
553
+ }
554
+
555
+ /** Construct planning delegate observability for pipeline and tests (SP-142). */
556
+ export function createPlanningDelegateObservability(input: {
557
+ path: PlanningDelegatePath;
558
+ primary_model_id?: string | null;
559
+ delegate_model_id?: string | null;
560
+ compressed_context?: PlanningDelegateObservability['compressed_context'];
561
+ planning_delegate_reason_code: string;
562
+ fallback_reason?: string | null;
563
+ }): PlanningDelegateObservability {
564
+ return {
565
+ path: input.path,
566
+ primary_model_id: input.primary_model_id ?? null,
567
+ delegate_model_id: input.delegate_model_id ?? null,
568
+ compressed_context: input.compressed_context ?? null,
569
+ planning_delegate_reason_code: input.planning_delegate_reason_code,
570
+ fallback_reason: input.fallback_reason ?? null,
571
+ };
572
+ }
573
+
574
+ /** Attach planning delegate observability to routing decision features (SP-142). */
575
+ export function enrichRoutingDecisionWithPlanningDelegate(
576
+ decision: RoutingDecision,
577
+ planningDelegate?: PlanningDelegateObservability | null,
578
+ ): RoutingDecision {
579
+ const observability = planningDelegate ?? buildPlanningDelegateObservability(decision);
580
+ if (!observability) {
581
+ return decision;
582
+ }
583
+
584
+ return {
585
+ ...decision,
586
+ features: {
587
+ ...(decision.features ?? emptyFeatureSidecar()),
588
+ planning_delegate: observability,
589
+ },
590
+ };
591
+ }
592
+
541
593
  /** Build privacy-safe SAAR pin state for explain and telemetry (SP-126). */
542
594
  export function buildSaarObservability(
543
595
  input: PinEconomicsObservabilityInput,
@@ -658,6 +710,53 @@ export const DEFAULT_BREAKEVEN_TELEMETRY_FIELDS = defaultBreakevenTelemetry();
658
710
  /** Default SAAR telemetry scalars for tests and legacy store reads. */
659
711
  export const DEFAULT_SAAR_TELEMETRY_FIELDS = defaultSaarTelemetry();
660
712
 
713
+ function defaultPlanningDelegateTelemetry(): Pick<
714
+ RoutingTelemetry,
715
+ | 'planning_delegate_path'
716
+ | 'planning_delegate_primary_model_id'
717
+ | 'planning_delegate_model_id'
718
+ | 'planning_delegate_reason_code'
719
+ | 'planning_delegate_fallback_reason'
720
+ | 'planning_delegate_max_messages'
721
+ | 'planning_delegate_max_tokens'
722
+ | 'planning_delegate_exclude_execution_history'
723
+ > {
724
+ return {
725
+ planning_delegate_path: null,
726
+ planning_delegate_primary_model_id: null,
727
+ planning_delegate_model_id: null,
728
+ planning_delegate_reason_code: null,
729
+ planning_delegate_fallback_reason: null,
730
+ planning_delegate_max_messages: null,
731
+ planning_delegate_max_tokens: null,
732
+ planning_delegate_exclude_execution_history: null,
733
+ };
734
+ }
735
+
736
+ /** Default planning delegate telemetry scalars for tests and legacy store reads. */
737
+ export const DEFAULT_PLANNING_DELEGATE_TELEMETRY_FIELDS = defaultPlanningDelegateTelemetry();
738
+
739
+ function planningDelegateTelemetryFromDecision(
740
+ decision: RoutingDecision,
741
+ ): ReturnType<typeof defaultPlanningDelegateTelemetry> {
742
+ const observability = buildPlanningDelegateObservability(decision);
743
+ if (!observability) {
744
+ return defaultPlanningDelegateTelemetry();
745
+ }
746
+
747
+ return {
748
+ planning_delegate_path: observability.path === 'none' ? null : observability.path,
749
+ planning_delegate_primary_model_id: observability.primary_model_id,
750
+ planning_delegate_model_id: observability.delegate_model_id,
751
+ planning_delegate_reason_code: observability.planning_delegate_reason_code,
752
+ planning_delegate_fallback_reason: observability.fallback_reason,
753
+ planning_delegate_max_messages: observability.compressed_context?.max_messages ?? null,
754
+ planning_delegate_max_tokens: observability.compressed_context?.max_tokens ?? null,
755
+ planning_delegate_exclude_execution_history:
756
+ observability.compressed_context?.exclude_execution_history ?? null,
757
+ };
758
+ }
759
+
661
760
  function pinEconomicsTelemetryFromInput(
662
761
  input: PinEconomicsObservabilityInput,
663
762
  ): ReturnType<typeof defaultBreakevenTelemetry> &
@@ -786,7 +885,9 @@ export async function enrichRoutingDecisionForExplain(
786
885
 
787
886
  return enrichRoutingDecisionWithPinEconomics(
788
887
  request,
789
- enrichRoutingDecisionWithTierSelection(withContextFit, clusterMatchTable),
888
+ enrichRoutingDecisionWithPlanningDelegate(
889
+ enrichRoutingDecisionWithTierSelection(withContextFit, clusterMatchTable),
890
+ ),
790
891
  pinEconomicsOptionsFromExplain(options),
791
892
  );
792
893
  }
@@ -847,12 +948,14 @@ export function buildRoutingDecisionLogPayload(
847
948
  ): Record<string, unknown> {
848
949
  const enriched = enrichRoutingDecisionWithPinEconomics(
849
950
  request,
850
- enrichRoutingDecisionWithTierSelection(
851
- enrichRoutingDecisionWithContextFit(
852
- request,
853
- decision,
854
- fleet,
855
- contextFitConfig,
951
+ enrichRoutingDecisionWithPlanningDelegate(
952
+ enrichRoutingDecisionWithTierSelection(
953
+ enrichRoutingDecisionWithContextFit(
954
+ request,
955
+ decision,
956
+ fleet,
957
+ contextFitConfig,
958
+ ),
856
959
  ),
857
960
  ),
858
961
  {
@@ -869,6 +972,7 @@ export function buildRoutingDecisionLogPayload(
869
972
  const tierSelection = enriched.features?.tier_selection;
870
973
  const breakeven = enriched.features?.breakeven;
871
974
  const saar = enriched.features?.saar;
975
+ const planningDelegate = enriched.features?.planning_delegate;
872
976
 
873
977
  return {
874
978
  request_id: enriched.request_id,
@@ -908,6 +1012,16 @@ export function buildRoutingDecisionLogPayload(
908
1012
  saar_reason_code: saar.saar_reason_code,
909
1013
  }
910
1014
  : null,
1015
+ planning_delegate_summary: planningDelegate
1016
+ ? {
1017
+ path: planningDelegate.path,
1018
+ primary_model_id: planningDelegate.primary_model_id,
1019
+ delegate_model_id: planningDelegate.delegate_model_id,
1020
+ compressed_context: planningDelegate.compressed_context,
1021
+ planning_delegate_reason_code: planningDelegate.planning_delegate_reason_code,
1022
+ fallback_reason: planningDelegate.fallback_reason,
1023
+ }
1024
+ : null,
911
1025
  delegate,
912
1026
  };
913
1027
  }
@@ -1093,6 +1207,7 @@ export class RoutingTelemetryEmitter {
1093
1207
  : {}),
1094
1208
  ...(this.saarConfig !== undefined ? { saarConfig: this.saarConfig } : {}),
1095
1209
  });
1210
+ const planningDelegateFields = planningDelegateTelemetryFromDecision(decision);
1096
1211
 
1097
1212
  const record: RoutingTelemetry = {
1098
1213
  timestamp: this.clock(),
@@ -1128,6 +1243,7 @@ export class RoutingTelemetryEmitter {
1128
1243
  saar_hard_lock: pinEconomicsFields.saar_hard_lock,
1129
1244
  turn_index_in_session: pinEconomicsFields.turn_index_in_session,
1130
1245
  saar_reason_code: pinEconomicsFields.saar_reason_code,
1246
+ ...planningDelegateFields,
1131
1247
  };
1132
1248
 
1133
1249
  this.entries.push(record);