pi-smart-router 0.3.0 → 0.5.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 (57) 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 +143 -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/matching/cluster-matcher.d.ts +23 -0
  12. package/dist/domain/matching/cluster-matcher.d.ts.map +1 -1
  13. package/dist/domain/matching/cluster-matcher.js +93 -12
  14. package/dist/domain/matching/cluster-matcher.js.map +1 -1
  15. package/dist/domain/pinning/session-pinner.d.ts +18 -4
  16. package/dist/domain/pinning/session-pinner.d.ts.map +1 -1
  17. package/dist/domain/pinning/session-pinner.js +70 -9
  18. package/dist/domain/pinning/session-pinner.js.map +1 -1
  19. package/dist/domain/pipeline/router-pipeline.d.ts +20 -2
  20. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  21. package/dist/domain/pipeline/router-pipeline.js +127 -19
  22. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  23. package/dist/domain/pricing/virtual-cost-v2.d.ts +62 -0
  24. package/dist/domain/pricing/virtual-cost-v2.d.ts.map +1 -0
  25. package/dist/domain/pricing/virtual-cost-v2.js +166 -0
  26. package/dist/domain/pricing/virtual-cost-v2.js.map +1 -0
  27. package/dist/domain/routing/expected-cost.d.ts +47 -3
  28. package/dist/domain/routing/expected-cost.d.ts.map +1 -1
  29. package/dist/domain/routing/expected-cost.js +133 -18
  30. package/dist/domain/routing/expected-cost.js.map +1 -1
  31. package/dist/domain/types/entities.d.ts +58 -0
  32. package/dist/domain/types/entities.d.ts.map +1 -1
  33. package/dist/domain/types/index.d.ts +1 -1
  34. package/dist/domain/types/index.d.ts.map +1 -1
  35. package/dist/domain/types/schemas.d.ts +47 -0
  36. package/dist/domain/types/schemas.d.ts.map +1 -1
  37. package/dist/domain/types/schemas.js +91 -0
  38. package/dist/domain/types/schemas.js.map +1 -1
  39. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
  40. package/dist/infrastructure/persistence/sqlite-store.js +2 -1
  41. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
  42. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +41 -3
  43. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  44. package/dist/infrastructure/telemetry/routing-telemetry.js +112 -5
  45. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  46. package/package.json +4 -1
  47. package/src/config/defaults.ts +6 -2
  48. package/src/domain/matching/cluster-matcher.ts +120 -11
  49. package/src/domain/pinning/session-pinner.ts +148 -11
  50. package/src/domain/pipeline/router-pipeline.ts +186 -28
  51. package/src/domain/pricing/virtual-cost-v2.ts +264 -0
  52. package/src/domain/routing/expected-cost.ts +206 -26
  53. package/src/domain/types/entities.ts +63 -0
  54. package/src/domain/types/index.ts +4 -0
  55. package/src/domain/types/schemas.ts +112 -0
  56. package/src/infrastructure/persistence/sqlite-store.ts +2 -0
  57. package/src/infrastructure/telemetry/routing-telemetry.ts +185 -11
@@ -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;