pi-smart-router 0.2.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 (61) 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 +51 -0
  7. package/config/benchmark-profiles.json +145 -0
  8. package/config/models.yaml.example +5 -0
  9. package/config/routing-calibration.json.example +14 -2
  10. package/dist/config/defaults.d.ts +2 -2
  11. package/dist/config/defaults.d.ts.map +1 -1
  12. package/dist/config/defaults.js +5 -3
  13. package/dist/config/defaults.js.map +1 -1
  14. package/dist/config/pi-model-mapper.d.ts +12 -2
  15. package/dist/config/pi-model-mapper.d.ts.map +1 -1
  16. package/dist/config/pi-model-mapper.js +91 -6
  17. package/dist/config/pi-model-mapper.js.map +1 -1
  18. package/dist/domain/matching/hydra-input.d.ts +6 -5
  19. package/dist/domain/matching/hydra-input.d.ts.map +1 -1
  20. package/dist/domain/matching/hydra-input.js +73 -6
  21. package/dist/domain/matching/hydra-input.js.map +1 -1
  22. package/dist/domain/pipeline/router-pipeline.d.ts +22 -1
  23. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  24. package/dist/domain/pipeline/router-pipeline.js +135 -22
  25. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  26. package/dist/domain/routing/isotonic-calibrator.d.ts +56 -0
  27. package/dist/domain/routing/isotonic-calibrator.d.ts.map +1 -0
  28. package/dist/domain/routing/isotonic-calibrator.js +187 -0
  29. package/dist/domain/routing/isotonic-calibrator.js.map +1 -0
  30. package/dist/domain/routing/p-success-classifier.d.ts +53 -7
  31. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -1
  32. package/dist/domain/routing/p-success-classifier.js +205 -21
  33. package/dist/domain/routing/p-success-classifier.js.map +1 -1
  34. package/dist/domain/types/entities.d.ts +54 -0
  35. package/dist/domain/types/entities.d.ts.map +1 -1
  36. package/dist/domain/types/index.d.ts +1 -1
  37. package/dist/domain/types/index.d.ts.map +1 -1
  38. package/dist/domain/types/schemas.d.ts +29 -0
  39. package/dist/domain/types/schemas.d.ts.map +1 -1
  40. package/dist/domain/types/schemas.js +57 -0
  41. package/dist/domain/types/schemas.js.map +1 -1
  42. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
  43. package/dist/infrastructure/persistence/sqlite-store.js +2 -1
  44. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
  45. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +20 -1
  46. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  47. package/dist/infrastructure/telemetry/routing-telemetry.js +82 -2
  48. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  49. package/package.json +6 -3
  50. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +29 -1
  51. package/src/config/defaults.ts +6 -2
  52. package/src/config/pi-model-mapper.ts +110 -6
  53. package/src/domain/matching/hydra-input.ts +86 -7
  54. package/src/domain/pipeline/router-pipeline.ts +195 -29
  55. package/src/domain/routing/isotonic-calibrator.ts +255 -0
  56. package/src/domain/routing/p-success-classifier.ts +299 -26
  57. package/src/domain/types/entities.ts +58 -0
  58. package/src/domain/types/index.ts +4 -0
  59. package/src/domain/types/schemas.ts +73 -0
  60. package/src/infrastructure/persistence/sqlite-store.ts +2 -0
  61. package/src/infrastructure/telemetry/routing-telemetry.ts +127 -7
@@ -94,6 +94,15 @@ export {
94
94
  initHydraMatcher,
95
95
  wireSmartRouterExtension,
96
96
  };
97
+ export {
98
+ buildCompressedDelegateContext,
99
+ defaultSpawnPlanningDelegate,
100
+ extractAssistantText,
101
+ injectPlanningDelegateObservation,
102
+ isPlanningDelegateActive,
103
+ PLANNING_DELEGATE_OBSERVATION_PREFIX,
104
+ resolvePlanningDelegatePath,
105
+ } from './planning-delegate.js';
97
106
  export { SMART_ROUTER_FULL_INVOCATIONS, SMART_ROUTER_USAGE } from './commands.js';
98
107
  export { routeAndDelegate } from './route-and-delegate.js';
99
108
  export {
@@ -1,4 +1,7 @@
1
- import { existsSync } from 'node:fs';
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
2
5
  import { dirname, join } from 'node:path';
3
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
7
 
@@ -15,41 +18,145 @@ type ResolveModelScopeFn = (
15
18
  modelRegistry: ModelRegistry,
16
19
  ) => Promise<ScopedModel[]>;
17
20
 
18
- function findPiCodingAgentDir(): string {
21
+ const PI_CODING_AGENT_PKG = '@earendil-works/pi-coding-agent';
22
+ const MODEL_RESOLVER_REL = 'dist/core/model-resolver.js';
23
+ const INSTALL_HINT =
24
+ 'Install @earendil-works/pi-coding-agent where pi can resolve it ' +
25
+ '(e.g. cd ~/.pi/agent/npm && npm install @earendil-works/pi-coding-agent).';
26
+
27
+ function hasModelResolver(pkgDir: string): boolean {
28
+ return existsSync(join(pkgDir, MODEL_RESOLVER_REL));
29
+ }
30
+
31
+ function collectAncestorPackageDirs(startDir: string, maxDepth = 12): string[] {
19
32
  const candidates: string[] = [];
20
- const roots = [dirname(fileURLToPath(import.meta.url)), process.cwd()];
21
-
22
- for (const start of roots) {
23
- let dir = start;
24
- for (let i = 0; i < 12; i++) {
25
- candidates.push(join(dir, 'node_modules/@earendil-works/pi-coding-agent'));
26
- const parent = dirname(dir);
27
- if (parent === dir) {
28
- break;
33
+ let dir = startDir;
34
+ for (let i = 0; i < maxDepth; i++) {
35
+ candidates.push(join(dir, 'node_modules', PI_CODING_AGENT_PKG));
36
+ const parent = dirname(dir);
37
+ if (parent === dir) {
38
+ break;
39
+ }
40
+ dir = parent;
41
+ }
42
+ return candidates;
43
+ }
44
+
45
+ function resolvePiBinaryPackageDirs(): string[] {
46
+ try {
47
+ const piBin = execSync('which pi', {
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ }).trim();
51
+ if (!piBin) {
52
+ return [];
53
+ }
54
+ return collectAncestorPackageDirs(dirname(realpathSync(piBin)), 8);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ function resolveViaModuleResolution(): string | undefined {
61
+ const attempts: Array<() => string> = [
62
+ () => fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PKG)),
63
+ () => createRequire(import.meta.url).resolve(`${PI_CODING_AGENT_PKG}/package.json`),
64
+ () => createRequire(import.meta.url).resolve(`${PI_CODING_AGENT_PKG}/${MODEL_RESOLVER_REL}`),
65
+ ];
66
+
67
+ for (const attempt of attempts) {
68
+ try {
69
+ const resolvedPath = attempt();
70
+ const marker = `node_modules/${PI_CODING_AGENT_PKG}`;
71
+ const markerIndex = resolvedPath.lastIndexOf(marker);
72
+ if (markerIndex === -1) {
73
+ continue;
29
74
  }
30
- dir = parent;
75
+ const pkgDir = resolvedPath.slice(0, markerIndex + marker.length);
76
+ if (hasModelResolver(pkgDir)) {
77
+ return pkgDir;
78
+ }
79
+ } catch {
80
+ // Try the next resolution strategy.
81
+ }
82
+ }
83
+
84
+ return undefined;
85
+ }
86
+
87
+ /** Locate pi-coding-agent on disk for the resolveModelScope shim. */
88
+ export function findPiCodingAgentDir(): string {
89
+ const seen = new Set<string>();
90
+ const candidates: string[] = [];
91
+
92
+ const addCandidate = (candidate: string): void => {
93
+ if (!seen.has(candidate)) {
94
+ seen.add(candidate);
95
+ candidates.push(candidate);
31
96
  }
97
+ };
98
+
99
+ const viaModuleResolution = resolveViaModuleResolution();
100
+ if (viaModuleResolution) {
101
+ addCandidate(viaModuleResolution);
102
+ }
103
+
104
+ for (const start of [dirname(fileURLToPath(import.meta.url)), process.cwd()]) {
105
+ for (const candidate of collectAncestorPackageDirs(start)) {
106
+ addCandidate(candidate);
107
+ }
108
+ }
109
+
110
+ addCandidate(join(homedir(), '.pi/agent/npm/node_modules', PI_CODING_AGENT_PKG));
111
+
112
+ if (process.env.HOMEBREW_PREFIX) {
113
+ addCandidate(join(process.env.HOMEBREW_PREFIX, 'lib/node_modules', PI_CODING_AGENT_PKG));
114
+ }
115
+
116
+ for (const candidate of resolvePiBinaryPackageDirs()) {
117
+ addCandidate(candidate);
32
118
  }
33
119
 
34
120
  for (const candidate of candidates) {
35
- if (existsSync(join(candidate, 'dist/core/model-resolver.js'))) {
121
+ if (hasModelResolver(candidate)) {
36
122
  return candidate;
37
123
  }
38
124
  }
39
125
 
40
- throw new Error('Unable to locate @earendil-works/pi-coding-agent for resolveModelScope');
126
+ throw new Error(
127
+ `Unable to locate ${PI_CODING_AGENT_PKG} for resolveModelScope. ${INSTALL_HINT}`,
128
+ );
41
129
  }
42
130
 
43
- const piPkgDir = findPiCodingAgentDir();
44
- const modelResolver = await import(
45
- pathToFileURL(join(piPkgDir, 'dist/core/model-resolver.js')).href
46
- );
47
- const piResolveModelScope = modelResolver.resolveModelScope as ResolveModelScopeFn;
131
+ async function loadResolveModelScopeFn(): Promise<ResolveModelScopeFn> {
132
+ try {
133
+ const pkg = await import('@earendil-works/pi-coding-agent');
134
+ const publicResolve = (pkg as { resolveModelScope?: ResolveModelScopeFn }).resolveModelScope;
135
+ if (typeof publicResolve === 'function') {
136
+ return publicResolve;
137
+ }
138
+ } catch {
139
+ // Fall back to filesystem discovery when the public export is unavailable.
140
+ }
141
+
142
+ const piPkgDir = findPiCodingAgentDir();
143
+ const modelResolver = await import(
144
+ pathToFileURL(join(piPkgDir, MODEL_RESOLVER_REL)).href
145
+ );
146
+ return modelResolver.resolveModelScope as ResolveModelScopeFn;
147
+ }
148
+
149
+ let resolveModelScopeFnPromise: Promise<ResolveModelScopeFn> | undefined;
150
+
151
+ function getResolveModelScopeFn(): Promise<ResolveModelScopeFn> {
152
+ resolveModelScopeFnPromise ??= loadResolveModelScopeFn();
153
+ return resolveModelScopeFnPromise;
154
+ }
48
155
 
49
156
  /** Delegate to pi's resolveModelScope (not yet on the public package export surface). */
50
157
  export function resolveModelScope(
51
158
  patterns: string[],
52
159
  modelRegistry: ModelRegistry,
53
160
  ): Promise<ScopedModel[]> {
54
- return piResolveModelScope(patterns, modelRegistry);
161
+ return getResolveModelScopeFn().then((fn) => fn(patterns, modelRegistry));
55
162
  }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Cache-preserving planning delegate (SP-144, #71).
3
+ *
4
+ * When the pipeline emits `planning_delegate`, run an ephemeral frontier sub-call
5
+ * on compressed context, inject the result as an observation, and keep primary
6
+ * inference on the pinned economical model. Falls back to direct frontier routing
7
+ * when sub-agent spawn is unavailable (pi has no native sub-agent API yet).
8
+ */
9
+
10
+ import {
11
+ type Api,
12
+ type AssistantMessage,
13
+ type Context,
14
+ type Message,
15
+ type Model,
16
+ type SimpleStreamOptions,
17
+ type TextContent,
18
+ } from '@earendil-works/pi-ai/compat';
19
+
20
+ import type {
21
+ CompressedContextSpec,
22
+ PlanningDelegateObservability,
23
+ RoutingDecision,
24
+ } from '../../../src/domain/types/index.js';
25
+ import {
26
+ createPlanningDelegateObservability,
27
+ enrichRoutingDecisionWithPlanningDelegate,
28
+ PLANNING_DELEGATE,
29
+ PLANNING_DELEGATE_UNAVAILABLE,
30
+ PLANNING_DIRECT_FRONTIER,
31
+ } from '../../../src/infrastructure/telemetry/routing-telemetry.js';
32
+ import { collectDelegatedStream } from './delegate-stream.js';
33
+ import { findFleetProfile, resolveRegistryModel } from './delegation-runtime.js';
34
+ import type { StreamDelegationDeps } from './types.js';
35
+
36
+ /** Prefix for injected planning observations visible to the primary model. */
37
+ export const PLANNING_DELEGATE_OBSERVATION_PREFIX =
38
+ '[smart-router planning delegate]' as const;
39
+
40
+ export type PlanningDelegateSpawnResult =
41
+ | { readonly ok: true; readonly observationText: string }
42
+ | { readonly ok: false; readonly reason: string };
43
+
44
+ /** Injectable sub-agent spawn hook (mocked in unit tests). */
45
+ export type PlanningDelegateSpawnFn = (
46
+ frontierModel: Model<Api>,
47
+ compressedContext: Context,
48
+ options: SimpleStreamOptions | undefined,
49
+ deps: StreamDelegationDeps,
50
+ ) => Promise<PlanningDelegateSpawnResult>;
51
+
52
+ export function isPlanningDelegateActive(
53
+ decision: RoutingDecision,
54
+ ): decision is RoutingDecision & {
55
+ features: { planning_delegate: PlanningDelegateObservability };
56
+ } {
57
+ const observability = decision.features?.planning_delegate;
58
+ return (
59
+ decision.reason_code === PLANNING_DELEGATE &&
60
+ observability?.path === 'delegate' &&
61
+ observability.delegate_model_id !== null
62
+ );
63
+ }
64
+
65
+ function isConversationalMessage(message: Message): boolean {
66
+ return message.role === 'user' || message.role === 'assistant';
67
+ }
68
+
69
+ function isExecutionTraceMessage(message: Message): boolean {
70
+ if (message.role === 'toolResult') {
71
+ return true;
72
+ }
73
+ if (message.role !== 'assistant') {
74
+ return false;
75
+ }
76
+ const blocks = message.content;
77
+ if (!Array.isArray(blocks) || blocks.length === 0) {
78
+ return false;
79
+ }
80
+ return blocks.every(
81
+ (block) => block.type === 'toolCall' || block.type === 'thinking',
82
+ );
83
+ }
84
+
85
+ function estimateContextTokens(context: Context): number {
86
+ let charCount = 0;
87
+ if (context.systemPrompt) {
88
+ charCount += context.systemPrompt.length;
89
+ }
90
+ for (const message of context.messages) {
91
+ if (typeof message.content === 'string') {
92
+ charCount += message.content.length;
93
+ continue;
94
+ }
95
+ for (const block of message.content) {
96
+ if (block.type === 'text') {
97
+ charCount += block.text.length;
98
+ } else if (block.type === 'thinking') {
99
+ charCount += block.thinking.length;
100
+ } else if (block.type === 'toolCall') {
101
+ charCount += JSON.stringify(block.arguments).length;
102
+ }
103
+ }
104
+ }
105
+ return Math.max(0, Math.ceil(charCount / 4));
106
+ }
107
+
108
+ /**
109
+ * Build compressed context for the ephemeral frontier sub-call (SP-142 limits).
110
+ * Excludes tool execution history when configured; caps message count and tokens.
111
+ */
112
+ export function buildCompressedDelegateContext(
113
+ context: Context,
114
+ spec: CompressedContextSpec | null | undefined,
115
+ ): Context {
116
+ if (!spec) {
117
+ return context;
118
+ }
119
+
120
+ let messages = context.messages.filter(isConversationalMessage);
121
+ if (spec.exclude_execution_history) {
122
+ messages = messages.filter((message) => !isExecutionTraceMessage(message));
123
+ }
124
+
125
+ if (messages.length > spec.max_messages) {
126
+ messages = messages.slice(-spec.max_messages);
127
+ }
128
+
129
+ while (messages.length > 1 && estimateContextTokens({ ...context, messages }) > spec.max_tokens) {
130
+ messages = messages.slice(1);
131
+ }
132
+
133
+ return {
134
+ ...context,
135
+ messages,
136
+ };
137
+ }
138
+
139
+ export function extractAssistantText(message: AssistantMessage | undefined): string {
140
+ if (!message) {
141
+ return '';
142
+ }
143
+ return message.content
144
+ .filter((block): block is TextContent => block.type === 'text')
145
+ .map((block) => block.text)
146
+ .join('\n')
147
+ .trim();
148
+ }
149
+
150
+ /** Inject frontier sub-call output as a user observation for the primary model. */
151
+ export function injectPlanningDelegateObservation(
152
+ context: Context,
153
+ observationText: string,
154
+ ): Context {
155
+ const trimmed = observationText.trim();
156
+ if (!trimmed) {
157
+ return context;
158
+ }
159
+
160
+ const observationMessage: Message = {
161
+ role: 'user',
162
+ content: `${PLANNING_DELEGATE_OBSERVATION_PREFIX}\n${trimmed}`,
163
+ timestamp: Date.now(),
164
+ };
165
+
166
+ return {
167
+ ...context,
168
+ messages: [...context.messages, observationMessage],
169
+ };
170
+ }
171
+
172
+ /** Default frontier sub-call via provider stream (ephemeral one-shot delegate). */
173
+ export async function defaultSpawnPlanningDelegate(
174
+ frontierModel: Model<Api>,
175
+ compressedContext: Context,
176
+ options: SimpleStreamOptions | undefined,
177
+ deps: StreamDelegationDeps,
178
+ ): Promise<PlanningDelegateSpawnResult> {
179
+ try {
180
+ const result = await collectDelegatedStream(
181
+ frontierModel,
182
+ compressedContext,
183
+ deps,
184
+ options,
185
+ );
186
+ if (result.failed || !result.finalMessage) {
187
+ return {
188
+ ok: false,
189
+ reason:
190
+ result.finalMessage?.errorMessage ??
191
+ 'planning delegate sub-call failed',
192
+ };
193
+ }
194
+
195
+ const observationText = extractAssistantText(result.finalMessage);
196
+ if (!observationText) {
197
+ return {
198
+ ok: false,
199
+ reason: 'planning delegate sub-call returned empty response',
200
+ };
201
+ }
202
+
203
+ return { ok: true, observationText };
204
+ } catch (error) {
205
+ return {
206
+ ok: false,
207
+ reason: error instanceof Error ? error.message : String(error),
208
+ };
209
+ }
210
+ }
211
+
212
+ export interface PlanningDelegateResolution {
213
+ readonly context: Context;
214
+ readonly decision: RoutingDecision;
215
+ readonly targetModelId: string;
216
+ readonly usedDelegatePath: boolean;
217
+ }
218
+
219
+ /**
220
+ * Resolve planning delegate path: sub-call + observation injection, or direct frontier fallback.
221
+ */
222
+ export async function resolvePlanningDelegatePath(
223
+ context: Context,
224
+ decision: RoutingDecision,
225
+ options: SimpleStreamOptions | undefined,
226
+ deps: StreamDelegationDeps,
227
+ ): Promise<PlanningDelegateResolution> {
228
+ const observability = decision.features!.planning_delegate!;
229
+ const delegateModelId = observability.delegate_model_id!;
230
+ const primaryModelId = decision.selected_model_id;
231
+
232
+ const frontierProfile = findFleetProfile(deps.fleet, delegateModelId);
233
+ const frontierModel = frontierProfile
234
+ ? resolveRegistryModel(deps.modelRegistry, frontierProfile)
235
+ : undefined;
236
+
237
+ if (!frontierModel) {
238
+ console.warn(
239
+ '[smart-router] planning delegate unavailable: frontier model missing from registry',
240
+ delegateModelId,
241
+ );
242
+ return applyPlanningDelegateDirectFallback(
243
+ context,
244
+ decision,
245
+ delegateModelId,
246
+ PLANNING_DELEGATE_UNAVAILABLE,
247
+ deps,
248
+ );
249
+ }
250
+
251
+ const compressedContext = buildCompressedDelegateContext(
252
+ context,
253
+ observability.compressed_context,
254
+ );
255
+ const spawnFn = deps.spawnPlanningDelegate ?? defaultSpawnPlanningDelegate;
256
+ const spawnResult = await spawnFn(frontierModel, compressedContext, options, deps);
257
+
258
+ if (!spawnResult.ok) {
259
+ console.warn(
260
+ '[smart-router] planning delegate sub-call failed, falling back to direct frontier route',
261
+ spawnResult.reason,
262
+ );
263
+ return applyPlanningDelegateDirectFallback(
264
+ context,
265
+ decision,
266
+ delegateModelId,
267
+ PLANNING_DELEGATE_UNAVAILABLE,
268
+ deps,
269
+ );
270
+ }
271
+
272
+ console.warn(
273
+ '[smart-router] planning delegate sub-call completed',
274
+ JSON.stringify({
275
+ primary_model_id: primaryModelId,
276
+ delegate_model_id: delegateModelId,
277
+ observation_chars: spawnResult.observationText.length,
278
+ }),
279
+ );
280
+
281
+ return {
282
+ context: injectPlanningDelegateObservation(context, spawnResult.observationText),
283
+ decision,
284
+ targetModelId: primaryModelId,
285
+ usedDelegatePath: true,
286
+ };
287
+ }
288
+
289
+ function applyPlanningDelegateDirectFallback(
290
+ context: Context,
291
+ decision: RoutingDecision,
292
+ delegateModelId: string,
293
+ fallbackReason: string,
294
+ deps: StreamDelegationDeps,
295
+ ): PlanningDelegateResolution {
296
+ const profile = findFleetProfile(deps.fleet, delegateModelId);
297
+ const fallbackDecision = enrichRoutingDecisionWithPlanningDelegate(
298
+ {
299
+ ...decision,
300
+ selected_model_id: delegateModelId,
301
+ tier: profile?.tier ?? decision.tier,
302
+ reason_code: PLANNING_DIRECT_FRONTIER,
303
+ },
304
+ createPlanningDelegateObservability({
305
+ path: 'direct',
306
+ delegate_model_id: delegateModelId,
307
+ planning_delegate_reason_code: PLANNING_DIRECT_FRONTIER,
308
+ fallback_reason: fallbackReason,
309
+ }),
310
+ );
311
+
312
+ return {
313
+ context,
314
+ decision: fallbackDecision,
315
+ targetModelId: delegateModelId,
316
+ usedDelegatePath: false,
317
+ };
318
+ }
@@ -34,6 +34,10 @@ import {
34
34
  } from './delegation-runtime.js';
35
35
  import { buildRoutingRequest } from './routing-context.js';
36
36
  import { capturePreRouteOutcomes, updateSessionRoutingSnapshot } from './routing-outcomes.js';
37
+ import {
38
+ isPlanningDelegateActive,
39
+ resolvePlanningDelegatePath,
40
+ } from './planning-delegate.js';
37
41
  import type { StreamDelegationDeps } from './types.js';
38
42
 
39
43
  function isRoutingLogEnabled(): boolean {
@@ -287,6 +291,22 @@ export async function routeAndDelegate(
287
291
  deps.datasetRecorder?.record(request, decision);
288
292
  updateSessionRoutingSnapshot(deps, sessionId, request, decision);
289
293
 
294
+ let delegationContext: Context = context;
295
+
296
+ if (isPlanningDelegateActive(decision)) {
297
+ const planningResolution = await resolvePlanningDelegatePath(
298
+ context,
299
+ decision,
300
+ options,
301
+ deps,
302
+ );
303
+ delegationContext = planningResolution.context;
304
+ decision = planningResolution.decision;
305
+ if (!planningResolution.usedDelegatePath) {
306
+ deps.onRoutingDecision?.(decision);
307
+ }
308
+ }
309
+
290
310
  let targetModel = resolveTargetModel(deps, decision);
291
311
  if (!targetModel) {
292
312
  console.warn(
@@ -374,7 +394,7 @@ export async function routeAndDelegate(
374
394
 
375
395
  const result = await delegateWithOutcome(
376
396
  targetModel,
377
- context,
397
+ delegationContext,
378
398
  deps,
379
399
  options,
380
400
  sessionId,
@@ -15,6 +15,7 @@ import type {
15
15
  PriceCatalog,
16
16
  RoutingDecision,
17
17
  } from '../../../src/domain/types/index.js';
18
+ import type { PlanningDelegateSpawnFn } from './planning-delegate.js';
18
19
  import type { StorePort } from '../../../src/domain/types/store-port.js';
19
20
  import {
20
21
  DatasetRecorder,
@@ -56,6 +57,8 @@ export interface StreamDelegationDeps {
56
57
  readonly executionLedger: ExecutionLedger;
57
58
  /** Injectable for tests; production uses pi-ai streamSimple. */
58
59
  delegateStream?: DelegateStreamFn;
60
+ /** Injectable planning delegate sub-call; production uses frontier stream delegate. */
61
+ spawnPlanningDelegate?: PlanningDelegateSpawnFn;
59
62
  readonly lifecycleHookState?: LifecycleHookState;
60
63
  readonly datasetRecorder?: DatasetRecorder;
61
64
  readonly outcomeRecorder?: OutcomeRecorder;
package/README.md CHANGED
@@ -344,6 +344,10 @@ Cluster IDs are stable reason-code prefixes (`cluster_low_stakes_general`, `clus
344
344
  | `SMART_ROUTER_DATASET_FINGERPRINT` | (unset) | Set to `1` (requires `SMART_ROUTER_DATASET=1`) to store an install-local HMAC-SHA256 fingerprint of each normalized prompt for duplicate detection within this install. The install pepper lives in `.pi-smart-router/.dataset-key` (gitignored) and is never exported. **Warning:** short or common prompts are vulnerable to offline rainbow-table guessing; use only when you accept that tradeoff. See [#10](https://github.com/beettlle/pi-smart-router/issues/10). |
345
345
  | `MODELS_YAML_PATH` | `./config/models.yaml` | Fleet catalog path (library API only) |
346
346
  | `SMART_ROUTER_PLANNING_TURN_BUFFER` | `2` | SAAR planning buffer: frontier planning turns allowed before hard-lock ([v0.2.0 Continuity](https://github.com/beettlle/pi-smart-router/issues/72)) |
347
+ | `SMART_ROUTER_PLANNING_DELEGATE_ENABLED` | `true` | Enable cache-preserving planning delegate ([#71](https://github.com/beettlle/pi-smart-router/issues/71)) |
348
+ | `SMART_ROUTER_PLANNING_DELEGATE_MAX_MESSAGES` | `12` | Compressed-context message cap for frontier sub-call |
349
+ | `SMART_ROUTER_PLANNING_DELEGATE_MAX_TOKENS` | `16384` | Compressed-context token cap for frontier sub-call |
350
+ | `SMART_ROUTER_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY` | `true` | Exclude tool execution history from delegate payload |
347
351
  | `SMART_ROUTER_PREFIX_CACHE_WEIGHT` | `0.20` | SAAR weight on warm prefix value in cache breakeven math (0–1; [#73](https://github.com/beettlle/pi-smart-router/issues/73)) |
348
352
  | `SMART_ROUTER_IDLE_TIMEOUT_SECONDS` | `300` | SAAR idle seconds before pin reopens for full re-route |
349
353
  | `SMART_ROUTER_SWITCH_THRESHOLD` | `0.5` | SAAR switch score gate (0–1) for tier upgrades during hard-lock |
@@ -371,6 +375,34 @@ v0.2.0 adds **Session-Aware Agentic Routing (SAAR)** pin knobs ([#72](https://gi
371
375
 
372
376
  See [routing-roadmap.md](docs/routing-roadmap.md) §2 P0 for design context.
373
377
 
378
+ ### Planning delegate (v0.4.0 Delegate)
379
+
380
+ When a **planning** turn would route primary inference to frontier while a warm **economical** session pin is active, smart-router prefers **cache-preserving delegation** ([#71](https://github.com/beettlle/pi-smart-router/issues/71)):
381
+
382
+ 1. **Pipeline** (`turn_envelope`) emits `planning_delegate` — primary stays on the pinned economical model; `features.planning_delegate` names the frontier **delegate** model and compressed-context limits.
383
+ 2. **Pi extension** (`.pi/extensions/smart-router`) runs an ephemeral frontier sub-call with compressed context (tool execution history excluded by default), injects the result as an observation user message, then delegates **primary** streaming to the pinned economical model.
384
+ 3. **Fallback** — when delegate is disabled, spawn fails, or the delegate model is missing from the registry, the extension falls back to a **direct frontier** route with a documented `fallback_reason` in explain/telemetry.
385
+
386
+ | Knob | Env var | Default | Effect |
387
+ |------|---------|---------|--------|
388
+ | Delegate enabled | `SMART_ROUTER_PLANNING_DELEGATE_ENABLED` | `true` | When `false`, SAAR buffer allows direct frontier planning (`planning_direct_frontier` + `planning_delegate_disabled`) |
389
+ | Compressed message cap | `SMART_ROUTER_PLANNING_DELEGATE_MAX_MESSAGES` | `12` | Max messages sent to the frontier sub-call |
390
+ | Compressed token cap | `SMART_ROUTER_PLANNING_DELEGATE_MAX_TOKENS` | `16384` | Token budget for compressed delegate context |
391
+ | Exclude tool history | `SMART_ROUTER_PLANNING_DELEGATE_EXCLUDE_EXECUTION_HISTORY` | `true` | Strip tool-call / tool-result turns from delegate payload |
392
+
393
+ **Coordination boundary with pi core:** smart-router owns **routing** (when to delegate, which models, compressed limits, fallback reason codes). **Sub-agent spawn and observation injection** run in the pi extension via `streamSimple` — pi core must expose a delegate/stream API the extension can call; smart-router does not orchestrate pi's outer sub-agent scheduler. Operators enabling `/model smart-router/auto` get delegate behavior automatically when the extension is loaded; no separate pi sub-agent config is required beyond a frontier model in the registry.
394
+
395
+ **Dogfood verification (planning delegate)**
396
+
397
+ 1. Start pi with routing logs: `SMART_ROUTER_LOG_ROUTING=1 pi` and `/model smart-router/auto`.
398
+ 2. Begin a session on an economical pin (routine prompts), then trigger planning turns (e.g. architecture or multi-step design work).
399
+ 3. Inspect stderr JSON — on delegate turns expect `reason_code: planning_delegate`, `planning_delegate_summary.path: "delegate"`, `primary_model_id` equal to the pin, and `delegate_model_id` pointing at frontier.
400
+ 4. Confirm primary inference stays on the economical model (cache-friendly) while stderr shows `[smart-router] planning delegate sub-call completed` with the frontier model id.
401
+ 5. Disable delegate (`SMART_ROUTER_PLANNING_DELEGATE_ENABLED=false`) and repeat — expect `planning_direct_frontier` with `fallback_reason: planning_delegate_disabled`.
402
+ 6. Use `pi router explain` (or `POST /v1/route/explain`) on the same session — `features.planning_delegate` mirrors live routing (`path: delegate` vs `direct`, `fallback_reason` when applicable).
403
+
404
+ See [routing-roadmap.md](docs/routing-roadmap.md) §2 P0 and GitHub [#71](https://github.com/beettlle/pi-smart-router/issues/71) for acceptance criteria.
405
+
374
406
  ### P(success) training export (baseline classifier)
375
407
 
376
408
  When `SMART_ROUTER_DATASET=1`, the router records privacy-safe dataset rows and behavioral outcome labels (model override, compaction pin break, `/smart-router feedback good|bad`). Export labeled training data from pi:
@@ -565,6 +597,25 @@ Contributors must run `npm run build` before publishing or consuming the library
565
597
  | `npm run routing:calibration-aggregate` | Aggregate community telemetry for calibration |
566
598
  | `npm run routing:train-calibration` | Train routing calibration artifact bundle |
567
599
  | `npm run routing:verify-calibration` | Verify calibration bundle against benchmark prompts |
600
+ | `npm run routing:ingest-benchmarks` | Regenerate `config/benchmark-profiles.json` from leaderboard fixtures |
601
+ | `npm run routing:verify-benchmark-profiles` | CI smoke: assert checked-in profiles match fixture ingest |
602
+
603
+ ### Benchmark profile refresh
604
+
605
+ Capability scores in `config/benchmark-profiles.json` are grounded from public leaderboard snapshots under `tests/fixtures/benchmark-leaderboards/`. Each artifact records provenance (`source_urls`, `scrape_date`, `catalog_freeze_date`) in its header.
606
+
607
+ **Operator policy:**
608
+
609
+ 1. **PR smoke** — `.github/workflows/benchmark-profile-refresh.yml` runs on PRs that touch fixtures, ingest, or the checked-in artifact. It executes `npm run routing:verify-benchmark-profiles` so fixture edits cannot drift from `config/benchmark-profiles.json`.
610
+ 2. **Monthly refresh** — the same workflow runs on the 1st of each month (06:00 UTC) and via `workflow_dispatch`. It re-ingests fixtures, updates `catalog_freeze_date` to the run date, and opens a PR when model scores change.
611
+ 3. **Manual updates** — after editing fixture snapshots, run `npm run routing:ingest-benchmarks` (optionally `--catalog-freeze-date YYYY-MM-DD`) and commit the regenerated `config/benchmark-profiles.json` with the PR.
612
+
613
+ Regenerate locally:
614
+
615
+ ```bash
616
+ npm run routing:ingest-benchmarks
617
+ npm run routing:verify-benchmark-profiles
618
+ ```
568
619
 
569
620
  ### Releasing
570
621