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.
- 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 +51 -0
- package/config/benchmark-profiles.json +145 -0
- package/config/models.yaml.example +5 -0
- package/config/routing-calibration.json.example +14 -2
- 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/config/pi-model-mapper.d.ts +12 -2
- package/dist/config/pi-model-mapper.d.ts.map +1 -1
- package/dist/config/pi-model-mapper.js +91 -6
- package/dist/config/pi-model-mapper.js.map +1 -1
- package/dist/domain/matching/hydra-input.d.ts +6 -5
- package/dist/domain/matching/hydra-input.d.ts.map +1 -1
- package/dist/domain/matching/hydra-input.js +73 -6
- package/dist/domain/matching/hydra-input.js.map +1 -1
- package/dist/domain/pipeline/router-pipeline.d.ts +22 -1
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
- package/dist/domain/pipeline/router-pipeline.js +135 -22
- package/dist/domain/pipeline/router-pipeline.js.map +1 -1
- package/dist/domain/routing/isotonic-calibrator.d.ts +56 -0
- package/dist/domain/routing/isotonic-calibrator.d.ts.map +1 -0
- package/dist/domain/routing/isotonic-calibrator.js +187 -0
- package/dist/domain/routing/isotonic-calibrator.js.map +1 -0
- package/dist/domain/routing/p-success-classifier.d.ts +53 -7
- package/dist/domain/routing/p-success-classifier.d.ts.map +1 -1
- package/dist/domain/routing/p-success-classifier.js +205 -21
- package/dist/domain/routing/p-success-classifier.js.map +1 -1
- package/dist/domain/types/entities.d.ts +54 -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 +29 -0
- package/dist/domain/types/schemas.d.ts.map +1 -1
- package/dist/domain/types/schemas.js +57 -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 +20 -1
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.js +82 -2
- package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
- package/package.json +6 -3
- package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +29 -1
- package/src/config/defaults.ts +6 -2
- package/src/config/pi-model-mapper.ts +110 -6
- package/src/domain/matching/hydra-input.ts +86 -7
- package/src/domain/pipeline/router-pipeline.ts +195 -29
- package/src/domain/routing/isotonic-calibrator.ts +255 -0
- package/src/domain/routing/p-success-classifier.ts +299 -26
- package/src/domain/types/entities.ts +58 -0
- package/src/domain/types/index.ts +4 -0
- package/src/domain/types/schemas.ts +73 -0
- package/src/infrastructure/persistence/sqlite-store.ts +2 -0
- 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 {
|
|
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;
|
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
|
|