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.
- package/.pi/extensions/smart-router/index.ts +9 -0
- package/.pi/extensions/smart-router/pi-model-scope.ts +127 -20
- package/.pi/extensions/smart-router/planning-delegate.ts +318 -0
- package/.pi/extensions/smart-router/route-and-delegate.ts +21 -1
- package/.pi/extensions/smart-router/types.ts +3 -0
- package/README.md +143 -0
- package/dist/config/defaults.d.ts +2 -2
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +5 -3
- package/dist/config/defaults.js.map +1 -1
- package/dist/domain/matching/cluster-matcher.d.ts +23 -0
- package/dist/domain/matching/cluster-matcher.d.ts.map +1 -1
- package/dist/domain/matching/cluster-matcher.js +93 -12
- package/dist/domain/matching/cluster-matcher.js.map +1 -1
- package/dist/domain/pinning/session-pinner.d.ts +18 -4
- package/dist/domain/pinning/session-pinner.d.ts.map +1 -1
- package/dist/domain/pinning/session-pinner.js +70 -9
- package/dist/domain/pinning/session-pinner.js.map +1 -1
- package/dist/domain/pipeline/router-pipeline.d.ts +20 -2
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
- package/dist/domain/pipeline/router-pipeline.js +127 -19
- package/dist/domain/pipeline/router-pipeline.js.map +1 -1
- package/dist/domain/pricing/virtual-cost-v2.d.ts +62 -0
- package/dist/domain/pricing/virtual-cost-v2.d.ts.map +1 -0
- package/dist/domain/pricing/virtual-cost-v2.js +166 -0
- package/dist/domain/pricing/virtual-cost-v2.js.map +1 -0
- package/dist/domain/routing/expected-cost.d.ts +47 -3
- package/dist/domain/routing/expected-cost.d.ts.map +1 -1
- package/dist/domain/routing/expected-cost.js +133 -18
- package/dist/domain/routing/expected-cost.js.map +1 -1
- package/dist/domain/types/entities.d.ts +58 -0
- package/dist/domain/types/entities.d.ts.map +1 -1
- package/dist/domain/types/index.d.ts +1 -1
- package/dist/domain/types/index.d.ts.map +1 -1
- package/dist/domain/types/schemas.d.ts +47 -0
- package/dist/domain/types/schemas.d.ts.map +1 -1
- package/dist/domain/types/schemas.js +91 -0
- package/dist/domain/types/schemas.js.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.js +2 -1
- package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts +41 -3
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.js +112 -5
- package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
- package/package.json +4 -1
- package/src/config/defaults.ts +6 -2
- package/src/domain/matching/cluster-matcher.ts +120 -11
- package/src/domain/pinning/session-pinner.ts +148 -11
- package/src/domain/pipeline/router-pipeline.ts +186 -28
- package/src/domain/pricing/virtual-cost-v2.ts +264 -0
- package/src/domain/routing/expected-cost.ts +206 -26
- package/src/domain/types/entities.ts +63 -0
- package/src/domain/types/index.ts +4 -0
- package/src/domain/types/schemas.ts +112 -0
- package/src/infrastructure/persistence/sqlite-store.ts +2 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 (
|
|
121
|
+
if (hasModelResolver(candidate)) {
|
|
36
122
|
return candidate;
|
|
37
123
|
}
|
|
38
124
|
}
|
|
39
125
|
|
|
40
|
-
throw new Error(
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Unable to locate ${PI_CODING_AGENT_PKG} for resolveModelScope. ${INSTALL_HINT}`,
|
|
128
|
+
);
|
|
41
129
|
}
|
|
42
130
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
);
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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;
|