ultimate-pi 0.15.0 → 0.17.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/.agents/skills/harness-governor/SKILL.md +11 -0
- package/.agents/skills/harness-orchestration/SKILL.md +3 -1
- package/.agents/skills/harness-plan/SKILL.md +5 -5
- package/.pi/agents/harness/adversary.md +1 -1
- package/.pi/agents/harness/evaluator.md +1 -1
- package/.pi/agents/harness/executor.md +1 -1
- package/.pi/agents/harness/incident-recorder.md +1 -1
- package/.pi/agents/harness/meta-optimizer.md +1 -1
- package/.pi/agents/harness/planning/decompose.md +4 -33
- package/.pi/agents/harness/planning/execution-plan-author.md +3 -2
- package/.pi/agents/harness/planning/hypothesis-validator.md +3 -2
- package/.pi/agents/harness/planning/hypothesis.md +4 -27
- package/.pi/agents/harness/planning/implementation-researcher.md +3 -2
- package/.pi/agents/harness/planning/plan-adversary.md +2 -3
- package/.pi/agents/harness/planning/plan-evaluator.md +3 -2
- package/.pi/agents/harness/planning/review-integrator.md +2 -3
- package/.pi/agents/harness/planning/scout-graphify.md +3 -22
- package/.pi/agents/harness/planning/scout-semantic.md +3 -18
- package/.pi/agents/harness/planning/scout-structure.md +3 -18
- package/.pi/agents/harness/planning/sprint-contract-auditor.md +3 -2
- package/.pi/agents/harness/planning/stack-researcher.md +3 -2
- package/.pi/agents/harness/tie-breaker.md +1 -1
- package/.pi/agents/harness/trace-librarian.md +1 -1
- package/.pi/extensions/budget-guard.ts +33 -19
- package/.pi/extensions/harness-debate-tools.ts +54 -6
- package/.pi/extensions/harness-run-context.ts +108 -2
- package/.pi/extensions/harness-subagent-submit.ts +172 -0
- package/.pi/extensions/harness-telemetry.ts +29 -4
- package/.pi/extensions/lib/debate-bus-core.ts +49 -6
- package/.pi/extensions/lib/harness-subagent-auth.ts +104 -19
- package/.pi/extensions/lib/harness-subagent-policy.ts +59 -0
- package/.pi/extensions/lib/harness-subagent-submit-pipeline.ts +82 -0
- package/.pi/extensions/lib/harness-subagent-submit-registry.ts +172 -0
- package/.pi/extensions/lib/harness-subagents-bridge.ts +127 -0
- package/.pi/extensions/lib/plan-debate-eligibility.ts +61 -8
- package/.pi/extensions/lib/plan-debate-focus.ts +21 -9
- package/.pi/extensions/lib/plan-debate-gate.ts +92 -18
- package/.pi/extensions/lib/plan-debate-lane.ts +15 -0
- package/.pi/extensions/lib/plan-debate-lanes.ts +27 -3
- package/.pi/extensions/lib/plan-debate-round-status.ts +18 -7
- package/.pi/extensions/lib/plan-messenger.ts +4 -0
- package/.pi/extensions/lib/plan-review-gate.ts +51 -0
- package/.pi/extensions/trace-recorder.ts +1 -0
- package/.pi/harness/agents.manifest.json +22 -22
- package/.pi/harness/docs/adrs/0037-subagent-submit-tools.md +31 -0
- package/.pi/harness/docs/adrs/0038-budget-telemetry-only.md +23 -0
- package/.pi/harness/docs/adrs/README.md +2 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/implementation-research.yaml +28 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/review-round-consolidated.yaml +25 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-packet.yaml +196 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-review.md +14 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/research-brief.yaml +62 -0
- package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +40 -17
- package/.pi/harness/specs/harness-executor-handoff.schema.json +19 -0
- package/.pi/harness/specs/harness-human-required.schema.json +16 -0
- package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
- package/.pi/harness/specs/plan-scout-findings.schema.json +19 -0
- package/.pi/lib/harness-agent-output.ts +45 -0
- package/.pi/lib/harness-budget-enforce.ts +18 -0
- package/.pi/lib/harness-schema-validate.ts +89 -0
- package/.pi/lib/harness-spawn-parse.ts +86 -0
- package/.pi/lib/harness-subagent-submit-path.ts +41 -0
- package/.pi/lib/harness-ui-state.ts +15 -2
- package/.pi/model-router.example.json +13 -4
- package/.pi/prompts/harness-auto.md +2 -2
- package/.pi/prompts/harness-plan.md +34 -14
- package/.pi/prompts/harness-run.md +2 -2
- package/.pi/prompts/harness-setup.md +4 -4
- package/.pi/scripts/harness-generate-model-router.mjs +118 -36
- package/.pi/scripts/harness-model-router-routing.test.mjs +97 -0
- package/.pi/scripts/harness-sync-model-router.mjs +15 -2
- package/.pi/scripts/harness-verify.mjs +31 -0
- package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
- package/CHANGELOG.md +21 -0
- package/package.json +4 -2
- package/vendor/pi-model-router/UPSTREAM_PIN.md +3 -1
- package/vendor/pi-model-router/extensions/commands.ts +4 -4
- package/vendor/pi-model-router/extensions/index.ts +21 -0
- package/vendor/pi-model-router/extensions/provider.ts +130 -79
- package/vendor/pi-model-router/extensions/routing.ts +148 -0
- package/vendor/pi-model-router/extensions/state.ts +3 -0
- package/vendor/pi-model-router/extensions/types.ts +9 -0
- package/vendor/pi-model-router/extensions/ui.ts +16 -2
- package/vendor/pi-subagents/src/subagents.ts +29 -3
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
- **Repository:** https://github.com/yeliu84/pi-model-router
|
|
4
4
|
- **License:** MIT (`LICENSE` in this tree)
|
|
5
5
|
- **Pinned upstream commit:** `8c60095da0e753c242c4be9bb617b85f4dd3255c`
|
|
6
|
-
- **Local changes:**
|
|
6
|
+
- **Local changes:**
|
|
7
|
+
- TypeScript imports in `extensions/*.ts` use `@earendil-works/*`; relative imports end in `.js` for NodeNext; `package.json` peerDependencies list `@earendil-works/*`.
|
|
8
|
+
- **Session-locked routing (ultimate-pi harness):** one concrete model per session/profile, chosen from initial prompt + system prompt complexity; per-turn routing adjusts **thinking tier only** (`sessionLock` persisted in `router-state`). Harness generator emits the same `model` on high/medium/low tiers.
|
|
7
9
|
|
|
8
10
|
**Refresh upstream:** run `npm run vendor:sync-router` from ultimate-pi root (updates this file with the latest commit SHA).
|
|
@@ -58,12 +58,12 @@ export const registerCommands = (
|
|
|
58
58
|
const SUBCOMMAND_DETAILS = [
|
|
59
59
|
{ name: 'status', desc: 'Show current router status' },
|
|
60
60
|
{ name: 'profile', desc: 'Switch to a different router profile' },
|
|
61
|
-
{ name: 'pin', desc: 'Pin
|
|
61
|
+
{ name: 'pin', desc: 'Pin thinking tier for a profile (model stays session-locked)' },
|
|
62
62
|
{ name: 'thinking', desc: 'Override thinking level for a tier or profile' },
|
|
63
63
|
{ name: 'disable', desc: 'Disable the router and restore last model' },
|
|
64
64
|
{
|
|
65
65
|
name: 'fix',
|
|
66
|
-
desc: 'Correct
|
|
66
|
+
desc: 'Correct last thinking tier and pin it (model stays session-locked)',
|
|
67
67
|
},
|
|
68
68
|
{ name: 'widget', desc: 'Toggle the router status widget' },
|
|
69
69
|
{ name: 'debug', desc: 'Toggle or clear router debug history' },
|
|
@@ -676,10 +676,10 @@ export const registerCommands = (
|
|
|
676
676
|
'Router Subcommands:',
|
|
677
677
|
' status Show current status, profile, pin, cost, and last decision.',
|
|
678
678
|
' profile [name] Switch to a profile (enables router if off). Lists available if no name.',
|
|
679
|
-
' pin [profile] <tier|auto>
|
|
679
|
+
' pin [profile] <tier|auto> Pin thinking tier (high|medium|low); model stays locked for the session.',
|
|
680
680
|
' thinking [prof] [tier] <lv> Override thinking level for a profile/tier (off|minimal|...|xhigh|auto).',
|
|
681
681
|
' disable Disable the router and restore the last used non-router model.',
|
|
682
|
-
' fix <tier> Correct
|
|
682
|
+
' fix <tier> Correct last thinking tier and pin it for the current profile.',
|
|
683
683
|
' widget <on|off|toggle> Control the persistent status widget visibility.',
|
|
684
684
|
' debug <on|off|show|clear> Control routing debug logging to notifications and history.',
|
|
685
685
|
' reload Hot-reload the configuration JSON from .pi/model-router.json.',
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type RouterThinkingByProfile,
|
|
11
11
|
type RouterTier,
|
|
12
12
|
type CustomSessionEntry,
|
|
13
|
+
type SessionLock,
|
|
13
14
|
} from './types.js';
|
|
14
15
|
import {
|
|
15
16
|
FALLBACK_CONFIG,
|
|
@@ -47,6 +48,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
47
48
|
let lastPersistedSnapshot: string | undefined;
|
|
48
49
|
let isInitialized = false;
|
|
49
50
|
let isInternalModelSwitch = false;
|
|
51
|
+
let sessionLock: SessionLock | undefined;
|
|
50
52
|
|
|
51
53
|
const setModelInternally = async (
|
|
52
54
|
model: NonNullable<ExtensionContext['model']>,
|
|
@@ -94,6 +96,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
94
96
|
lastDecision,
|
|
95
97
|
lastNonRouterModel,
|
|
96
98
|
accumulatedCost,
|
|
99
|
+
sessionLock,
|
|
97
100
|
);
|
|
98
101
|
const snapshot = JSON.stringify({
|
|
99
102
|
...state,
|
|
@@ -127,6 +130,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
127
130
|
accumulatedCost,
|
|
128
131
|
widgetEnabled,
|
|
129
132
|
currentConfig,
|
|
133
|
+
sessionLock,
|
|
130
134
|
),
|
|
131
135
|
reloadConfig: (
|
|
132
136
|
ctx?: ExtensionContext,
|
|
@@ -204,6 +208,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
204
208
|
}
|
|
205
209
|
selectedProfile = resolvedProfile;
|
|
206
210
|
routerEnabled = true;
|
|
211
|
+
sessionLock = undefined;
|
|
207
212
|
persistState();
|
|
208
213
|
actions.updateStatus(ctx);
|
|
209
214
|
return true;
|
|
@@ -253,6 +258,12 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
253
258
|
set accumulatedCost(v) {
|
|
254
259
|
accumulatedCost = v;
|
|
255
260
|
},
|
|
261
|
+
get sessionLock() {
|
|
262
|
+
return sessionLock;
|
|
263
|
+
},
|
|
264
|
+
set sessionLock(v) {
|
|
265
|
+
sessionLock = v;
|
|
266
|
+
},
|
|
256
267
|
},
|
|
257
268
|
{
|
|
258
269
|
persistState,
|
|
@@ -290,6 +301,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
290
301
|
? `${ctx.model.provider}/${ctx.model.id}`
|
|
291
302
|
: lastNonRouterModel;
|
|
292
303
|
lastDecision = undefined;
|
|
304
|
+
sessionLock = undefined;
|
|
293
305
|
|
|
294
306
|
const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
|
|
295
307
|
const savedState = entries
|
|
@@ -322,6 +334,12 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
322
334
|
: [];
|
|
323
335
|
lastNonRouterModel = savedState.lastNonRouterModel ?? lastNonRouterModel;
|
|
324
336
|
accumulatedCost = savedState.accumulatedCost ?? 0;
|
|
337
|
+
if (
|
|
338
|
+
savedState.sessionLock &&
|
|
339
|
+
savedState.sessionLock.profile === selectedProfile
|
|
340
|
+
) {
|
|
341
|
+
sessionLock = { ...savedState.sessionLock };
|
|
342
|
+
}
|
|
325
343
|
}
|
|
326
344
|
|
|
327
345
|
await actions.ensureValidActiveRouterProfile(ctx);
|
|
@@ -432,6 +450,9 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
432
450
|
}
|
|
433
451
|
|
|
434
452
|
routerEnabled = true;
|
|
453
|
+
if (selectedProfile !== profileName) {
|
|
454
|
+
sessionLock = undefined;
|
|
455
|
+
}
|
|
435
456
|
selectedProfile = profileName;
|
|
436
457
|
} else {
|
|
437
458
|
routerEnabled = false;
|
|
@@ -19,15 +19,21 @@ import type {
|
|
|
19
19
|
RouterTier,
|
|
20
20
|
RouterPinByProfile,
|
|
21
21
|
RouterThinkingByProfile,
|
|
22
|
+
SessionLock,
|
|
22
23
|
} from './types.js';
|
|
23
24
|
import { profileNames, parseCanonicalModelRef, ROUTER_TIERS } from './config.js';
|
|
24
25
|
import {
|
|
25
26
|
phaseForTier,
|
|
26
27
|
buildRoutingDecision,
|
|
27
28
|
decideRouting,
|
|
29
|
+
decideSessionLock,
|
|
28
30
|
runClassifier,
|
|
29
31
|
extractTextFromContent,
|
|
30
32
|
hasImageAttachment,
|
|
33
|
+
buildSessionLockContext,
|
|
34
|
+
sessionLockToRoutingDecision,
|
|
35
|
+
routingDecisionToSessionLock,
|
|
36
|
+
applyThinkingToDecision,
|
|
31
37
|
} from './routing.js';
|
|
32
38
|
|
|
33
39
|
export const createErrorMessage = (
|
|
@@ -138,6 +144,8 @@ export const registerRouterProvider = (
|
|
|
138
144
|
readonly thinkingByProfile: RouterThinkingByProfile;
|
|
139
145
|
readonly pinnedTierByProfile: RouterPinByProfile;
|
|
140
146
|
accumulatedCost: number;
|
|
147
|
+
get sessionLock(): SessionLock | undefined;
|
|
148
|
+
set sessionLock(v: SessionLock | undefined);
|
|
141
149
|
},
|
|
142
150
|
actions: {
|
|
143
151
|
persistState: () => void;
|
|
@@ -217,26 +225,111 @@ export const registerRouterProvider = (
|
|
|
217
225
|
state.routerEnabled = true;
|
|
218
226
|
|
|
219
227
|
const pinnedTier = state.pinnedTierByProfile[model.id];
|
|
228
|
+
const thinkingOverrides = state.thinkingByProfile[model.id];
|
|
220
229
|
const isBudgetExceeded =
|
|
221
230
|
state.currentConfig.maxSessionBudget !== undefined &&
|
|
222
231
|
state.accumulatedCost >= state.currentConfig.maxSessionBudget;
|
|
223
232
|
|
|
224
|
-
|
|
233
|
+
const checkModelSupportsImage = (modelRef: string) => {
|
|
234
|
+
try {
|
|
235
|
+
const { provider, modelId } = parseCanonicalModelRef(modelRef);
|
|
236
|
+
const m = state.currentModelRegistry?.find(provider, modelId);
|
|
237
|
+
return m?.input?.includes('image') ?? false;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const pickVisionCapableLock = (
|
|
244
|
+
lockDecision: RoutingDecision,
|
|
245
|
+
): RoutingDecision => {
|
|
246
|
+
if (!hasImageAttachment(context)) return lockDecision;
|
|
247
|
+
const candidates = [
|
|
248
|
+
lockDecision.targetLabel,
|
|
249
|
+
...(profile[lockDecision.tier].fallbacks ?? []),
|
|
250
|
+
];
|
|
251
|
+
if (candidates.some(checkModelSupportsImage)) return lockDecision;
|
|
252
|
+
for (const tier of ROUTER_TIERS) {
|
|
253
|
+
const refs = [profile[tier].model, ...(profile[tier].fallbacks ?? [])];
|
|
254
|
+
const visionRef = refs.find(checkModelSupportsImage);
|
|
255
|
+
if (visionRef) {
|
|
256
|
+
const { provider, modelId } = parseCanonicalModelRef(visionRef);
|
|
257
|
+
return {
|
|
258
|
+
...lockDecision,
|
|
259
|
+
tier,
|
|
260
|
+
targetLabel: visionRef,
|
|
261
|
+
targetProvider: provider,
|
|
262
|
+
targetModelId: modelId,
|
|
263
|
+
reasoning: `${lockDecision.reasoning} | Vision-capable model for image input.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return lockDecision;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let lock = state.sessionLock;
|
|
271
|
+
if (!lock || lock.profile !== model.id) {
|
|
272
|
+
const lockContext = buildSessionLockContext(context);
|
|
273
|
+
let lockDecision = decideSessionLock(
|
|
274
|
+
lockContext,
|
|
275
|
+
model.id,
|
|
276
|
+
profile,
|
|
277
|
+
pinnedTier,
|
|
278
|
+
thinkingOverrides,
|
|
279
|
+
state.currentConfig.phaseBias,
|
|
280
|
+
state.currentConfig.rules,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
state.currentConfig.classifierModel &&
|
|
285
|
+
!pinnedTier &&
|
|
286
|
+
!lockDecision.isRuleMatched
|
|
287
|
+
) {
|
|
288
|
+
const classifierResult = await runClassifier(
|
|
289
|
+
state.currentConfig.classifierModel,
|
|
290
|
+
state.currentModelRegistry,
|
|
291
|
+
lockContext,
|
|
292
|
+
undefined,
|
|
293
|
+
);
|
|
294
|
+
if (classifierResult) {
|
|
295
|
+
lockDecision = buildRoutingDecision(
|
|
296
|
+
model.id,
|
|
297
|
+
profile,
|
|
298
|
+
classifierResult.tier,
|
|
299
|
+
phaseForTier(classifierResult.tier),
|
|
300
|
+
`Session lock classifier: ${classifierResult.reasoning}`,
|
|
301
|
+
thinkingOverrides,
|
|
302
|
+
true,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
lockDecision = pickVisionCapableLock(lockDecision);
|
|
308
|
+
lock = routingDecisionToSessionLock(lockDecision);
|
|
309
|
+
state.sessionLock = lock;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const lockedBase = sessionLockToRoutingDecision(
|
|
313
|
+
lock,
|
|
314
|
+
profile,
|
|
315
|
+
thinkingOverrides,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
let thinkingDecision: RoutingDecision = decideRouting(
|
|
225
319
|
context,
|
|
226
320
|
model.id,
|
|
227
321
|
profile,
|
|
228
322
|
state.lastDecision,
|
|
229
323
|
pinnedTier,
|
|
230
|
-
|
|
324
|
+
thinkingOverrides,
|
|
231
325
|
state.currentConfig.phaseBias,
|
|
232
326
|
state.currentConfig.rules,
|
|
233
327
|
isBudgetExceeded,
|
|
234
328
|
);
|
|
235
329
|
|
|
236
|
-
// Optional Context Trigger Upgrade
|
|
237
330
|
if (
|
|
238
331
|
state.currentConfig.largeContextThreshold &&
|
|
239
|
-
|
|
332
|
+
thinkingDecision.tier !== 'high' &&
|
|
240
333
|
state.lastExtensionContext
|
|
241
334
|
) {
|
|
242
335
|
try {
|
|
@@ -245,28 +338,24 @@ export const registerRouterProvider = (
|
|
|
245
338
|
usage?.tokens &&
|
|
246
339
|
usage.tokens > state.currentConfig.largeContextThreshold
|
|
247
340
|
) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
'
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
false,
|
|
256
|
-
);
|
|
257
|
-
decision.isContextTriggered = true;
|
|
341
|
+
thinkingDecision = {
|
|
342
|
+
...thinkingDecision,
|
|
343
|
+
tier: 'high',
|
|
344
|
+
phase: 'planning',
|
|
345
|
+
reasoning: `Context usage (${usage.tokens}) exceeds threshold (${state.currentConfig.largeContextThreshold}). Forced high thinking.`,
|
|
346
|
+
isContextTriggered: true,
|
|
347
|
+
};
|
|
258
348
|
}
|
|
259
|
-
} catch (
|
|
349
|
+
} catch (_e) {
|
|
260
350
|
// ignore
|
|
261
351
|
}
|
|
262
352
|
}
|
|
263
353
|
|
|
264
|
-
// Classifier Override
|
|
265
354
|
if (
|
|
266
355
|
state.currentConfig.classifierModel &&
|
|
267
356
|
!pinnedTier &&
|
|
268
|
-
!
|
|
269
|
-
!
|
|
357
|
+
!thinkingDecision.isContextTriggered &&
|
|
358
|
+
!thinkingDecision.isRuleMatched
|
|
270
359
|
) {
|
|
271
360
|
const classifierResult = await runClassifier(
|
|
272
361
|
state.currentConfig.classifierModel,
|
|
@@ -275,24 +364,34 @@ export const registerRouterProvider = (
|
|
|
275
364
|
state.lastDecision?.phase,
|
|
276
365
|
);
|
|
277
366
|
if (classifierResult) {
|
|
278
|
-
|
|
367
|
+
thinkingDecision = buildRoutingDecision(
|
|
279
368
|
model.id,
|
|
280
369
|
profile,
|
|
281
370
|
classifierResult.tier,
|
|
282
371
|
phaseForTier(classifierResult.tier),
|
|
283
|
-
`
|
|
284
|
-
|
|
372
|
+
`Thinking classifier: ${classifierResult.reasoning}`,
|
|
373
|
+
thinkingOverrides,
|
|
285
374
|
true,
|
|
286
375
|
);
|
|
287
|
-
if (isBudgetExceeded &&
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
376
|
+
if (isBudgetExceeded && thinkingDecision.tier === 'high') {
|
|
377
|
+
thinkingDecision = {
|
|
378
|
+
...thinkingDecision,
|
|
379
|
+
tier: 'medium',
|
|
380
|
+
phase: 'implementation',
|
|
381
|
+
reasoning: `Budget exceeded. Downgraded thinking to medium. (Original: ${thinkingDecision.reasoning})`,
|
|
382
|
+
isBudgetForced: true,
|
|
383
|
+
};
|
|
292
384
|
}
|
|
293
385
|
}
|
|
294
386
|
}
|
|
295
387
|
|
|
388
|
+
let decision = applyThinkingToDecision(
|
|
389
|
+
lockedBase,
|
|
390
|
+
thinkingDecision,
|
|
391
|
+
profile,
|
|
392
|
+
thinkingOverrides,
|
|
393
|
+
);
|
|
394
|
+
|
|
296
395
|
const lastMessage = context.messages[context.messages.length - 1];
|
|
297
396
|
const previousDecision = state.lastDecision;
|
|
298
397
|
const isGoogleThinkingToolContinuation =
|
|
@@ -302,7 +401,7 @@ export const registerRouterProvider = (
|
|
|
302
401
|
previousDecision.thinking !== 'off' &&
|
|
303
402
|
decision.targetProvider === 'google' &&
|
|
304
403
|
decision.thinking !== 'off' &&
|
|
305
|
-
previousDecision.targetLabel
|
|
404
|
+
previousDecision.targetLabel === decision.targetLabel;
|
|
306
405
|
|
|
307
406
|
if (isGoogleThinkingToolContinuation) {
|
|
308
407
|
decision = {
|
|
@@ -319,56 +418,6 @@ export const registerRouterProvider = (
|
|
|
319
418
|
};
|
|
320
419
|
}
|
|
321
420
|
|
|
322
|
-
const imageAttached = hasImageAttachment(context);
|
|
323
|
-
if (imageAttached) {
|
|
324
|
-
const checkModelSupportsImage = (modelRef: string) => {
|
|
325
|
-
try {
|
|
326
|
-
const { provider, modelId } = parseCanonicalModelRef(modelRef);
|
|
327
|
-
const m = state.currentModelRegistry?.find(provider, modelId);
|
|
328
|
-
return m?.input?.includes('image') ?? false;
|
|
329
|
-
} catch {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
const tierModels = [
|
|
335
|
-
decision.targetLabel,
|
|
336
|
-
...(profile[decision.tier].fallbacks ?? []),
|
|
337
|
-
];
|
|
338
|
-
if (!tierModels.some(checkModelSupportsImage)) {
|
|
339
|
-
const tiersToTry: RouterTier[] =
|
|
340
|
-
decision.tier === 'low'
|
|
341
|
-
? ['medium', 'high']
|
|
342
|
-
: decision.tier === 'medium'
|
|
343
|
-
? ['high']
|
|
344
|
-
: [];
|
|
345
|
-
|
|
346
|
-
let foundTier: RouterTier | undefined;
|
|
347
|
-
for (const t of tiersToTry) {
|
|
348
|
-
const tModels = [
|
|
349
|
-
profile[t].model,
|
|
350
|
-
...(profile[t].fallbacks ?? []),
|
|
351
|
-
];
|
|
352
|
-
if (tModels.some(checkModelSupportsImage)) {
|
|
353
|
-
foundTier = t;
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if (foundTier) {
|
|
359
|
-
decision = buildRoutingDecision(
|
|
360
|
-
model.id,
|
|
361
|
-
profile,
|
|
362
|
-
foundTier,
|
|
363
|
-
phaseForTier(foundTier),
|
|
364
|
-
`Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
|
|
365
|
-
state.thinkingByProfile[model.id],
|
|
366
|
-
false,
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
421
|
state.lastDecision = decision;
|
|
373
422
|
actions.recordDebugDecision(decision);
|
|
374
423
|
|
|
@@ -376,10 +425,12 @@ export const registerRouterProvider = (
|
|
|
376
425
|
actions.updateStatus(state.lastExtensionContext);
|
|
377
426
|
}
|
|
378
427
|
|
|
428
|
+
const lockTier = lock.tier;
|
|
379
429
|
let modelsToTry = [
|
|
380
|
-
|
|
381
|
-
...(profile[
|
|
430
|
+
lock.modelRef,
|
|
431
|
+
...(profile[lockTier].fallbacks ?? []),
|
|
382
432
|
];
|
|
433
|
+
const imageAttached = hasImageAttachment(context);
|
|
383
434
|
if (imageAttached) {
|
|
384
435
|
modelsToTry = modelsToTry.filter((modelRef) => {
|
|
385
436
|
try {
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
RoutingDecision,
|
|
8
8
|
RoutingRule,
|
|
9
9
|
RouterThinkingByTier,
|
|
10
|
+
SessionLock,
|
|
10
11
|
} from './types.js';
|
|
11
12
|
import { parseCanonicalModelRef, isRouterTier } from './config.js';
|
|
12
13
|
|
|
@@ -38,6 +39,153 @@ export const getLastUserText = (context: Context): string => {
|
|
|
38
39
|
return '';
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
export const getFirstUserText = (context: Context): string => {
|
|
43
|
+
for (const message of context.messages) {
|
|
44
|
+
if (message.role === 'user') {
|
|
45
|
+
return extractTextFromContent(message.content).trim();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Context for one-shot session model lock: system prompt + first user message only. */
|
|
52
|
+
export const buildSessionLockContext = (context: Context): Context => {
|
|
53
|
+
const firstUser = context.messages.find((message) => message.role === 'user');
|
|
54
|
+
const messages = firstUser ? [firstUser] : context.messages.slice(0, 1);
|
|
55
|
+
return { ...context, messages };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const sessionLockToRoutingDecision = (
|
|
59
|
+
lock: SessionLock,
|
|
60
|
+
profile: RouterProfile,
|
|
61
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
62
|
+
): RoutingDecision => {
|
|
63
|
+
const routed = profile[lock.tier];
|
|
64
|
+
const { provider, modelId } = parseCanonicalModelRef(lock.modelRef);
|
|
65
|
+
const baseThinking =
|
|
66
|
+
routed.thinking ??
|
|
67
|
+
(lock.tier === 'high' ? 'high' : lock.tier === 'low' ? 'low' : 'medium');
|
|
68
|
+
const effectiveThinking = thinkingOverrides?.[lock.tier] ?? baseThinking;
|
|
69
|
+
return {
|
|
70
|
+
profile: lock.profile,
|
|
71
|
+
tier: lock.tier,
|
|
72
|
+
phase: phaseForTier(lock.tier),
|
|
73
|
+
targetProvider: provider,
|
|
74
|
+
targetModelId: modelId,
|
|
75
|
+
targetLabel: lock.modelRef,
|
|
76
|
+
reasoning: lock.reasoning,
|
|
77
|
+
thinking: effectiveThinking,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const routingDecisionToSessionLock = (
|
|
83
|
+
decision: RoutingDecision,
|
|
84
|
+
): SessionLock => ({
|
|
85
|
+
profile: decision.profile,
|
|
86
|
+
tier: decision.tier,
|
|
87
|
+
modelRef: decision.targetLabel,
|
|
88
|
+
reasoning: decision.reasoning,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** Text used to score initial session complexity (system prompt + first user message). */
|
|
92
|
+
export const getSessionLockAnalysisText = (context: Context): string => {
|
|
93
|
+
const lockContext = buildSessionLockContext(context);
|
|
94
|
+
const system = (context.systemPrompt ?? '').trim();
|
|
95
|
+
const firstUser = getFirstUserText(lockContext);
|
|
96
|
+
if (system && firstUser) return `${system}\n\n${firstUser}`;
|
|
97
|
+
return system || firstUser || '';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One-shot tier pick for session model lock (no prior-turn stickiness).
|
|
102
|
+
*/
|
|
103
|
+
export const decideSessionLock = (
|
|
104
|
+
context: Context,
|
|
105
|
+
profileName: string,
|
|
106
|
+
profile: RouterProfile,
|
|
107
|
+
pinnedTier?: RouterTier,
|
|
108
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
109
|
+
phaseBias = 0.5,
|
|
110
|
+
rules?: RoutingRule[],
|
|
111
|
+
): RoutingDecision => {
|
|
112
|
+
const analysisText = getSessionLockAnalysisText(context);
|
|
113
|
+
const synthetic: Context = {
|
|
114
|
+
systemPrompt: context.systemPrompt,
|
|
115
|
+
messages: analysisText
|
|
116
|
+
? [{ role: 'user', content: analysisText, timestamp: Date.now() }]
|
|
117
|
+
: [],
|
|
118
|
+
};
|
|
119
|
+
return decideRouting(
|
|
120
|
+
synthetic,
|
|
121
|
+
profileName,
|
|
122
|
+
profile,
|
|
123
|
+
undefined,
|
|
124
|
+
pinnedTier,
|
|
125
|
+
thinkingOverrides,
|
|
126
|
+
phaseBias,
|
|
127
|
+
rules,
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Per-turn thinking tier merged onto a session-locked model decision. */
|
|
133
|
+
export const applyThinkingToDecision = (
|
|
134
|
+
locked: RoutingDecision,
|
|
135
|
+
thinkingDecision: RoutingDecision,
|
|
136
|
+
profile: RouterProfile,
|
|
137
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
138
|
+
): RoutingDecision => {
|
|
139
|
+
const tier = thinkingDecision.tier;
|
|
140
|
+
const routed = profile[tier];
|
|
141
|
+
const baseThinking =
|
|
142
|
+
routed.thinking ??
|
|
143
|
+
(tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
|
|
144
|
+
const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
profile: locked.profile,
|
|
148
|
+
tier,
|
|
149
|
+
phase: thinkingDecision.phase,
|
|
150
|
+
targetProvider: locked.targetProvider,
|
|
151
|
+
targetModelId: locked.targetModelId,
|
|
152
|
+
targetLabel: locked.targetLabel,
|
|
153
|
+
reasoning: `${locked.reasoning} | Thinking: ${thinkingDecision.reasoning}`,
|
|
154
|
+
thinking: effectiveThinking,
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
isClassifier: thinkingDecision.isClassifier,
|
|
157
|
+
isRuleMatched: thinkingDecision.isRuleMatched,
|
|
158
|
+
isBudgetForced: thinkingDecision.isBudgetForced,
|
|
159
|
+
isContextTriggered: thinkingDecision.isContextTriggered,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Harness subagents: tier from system prompt + optional first user line. */
|
|
164
|
+
export const resolveTierFromPrompt = (
|
|
165
|
+
systemPrompt: string,
|
|
166
|
+
userText: string,
|
|
167
|
+
profileName: string,
|
|
168
|
+
profile: RouterProfile,
|
|
169
|
+
rules?: RoutingRule[],
|
|
170
|
+
phaseBias = 0.5,
|
|
171
|
+
): RouterTier => {
|
|
172
|
+
const context: Context = {
|
|
173
|
+
systemPrompt,
|
|
174
|
+
messages: userText
|
|
175
|
+
? [{ role: 'user', content: userText, timestamp: Date.now() }]
|
|
176
|
+
: [],
|
|
177
|
+
};
|
|
178
|
+
return decideSessionLock(
|
|
179
|
+
context,
|
|
180
|
+
profileName,
|
|
181
|
+
profile,
|
|
182
|
+
undefined,
|
|
183
|
+
undefined,
|
|
184
|
+
phaseBias,
|
|
185
|
+
rules,
|
|
186
|
+
).tier;
|
|
187
|
+
};
|
|
188
|
+
|
|
41
189
|
export const getRecentConversationText = (
|
|
42
190
|
context: Context,
|
|
43
191
|
limit = 6,
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
RouterThinkingByProfile,
|
|
4
4
|
RoutingDecision,
|
|
5
5
|
RouterPersistedState,
|
|
6
|
+
SessionLock,
|
|
6
7
|
} from './types.js';
|
|
7
8
|
|
|
8
9
|
export const isRouterPersistedState = (
|
|
@@ -30,6 +31,7 @@ export const buildPersistedState = (
|
|
|
30
31
|
lastDecision: RoutingDecision | undefined,
|
|
31
32
|
lastNonRouterModel: string | undefined,
|
|
32
33
|
accumulatedCost: number,
|
|
34
|
+
sessionLock: SessionLock | undefined,
|
|
33
35
|
): RouterPersistedState => {
|
|
34
36
|
return {
|
|
35
37
|
enabled: routerEnabled,
|
|
@@ -37,6 +39,7 @@ export const buildPersistedState = (
|
|
|
37
39
|
pinTier: pinnedTierByProfile[selectedProfile],
|
|
38
40
|
pinByProfile: { ...pinnedTierByProfile },
|
|
39
41
|
thinkingByProfile: { ...thinkingByProfile },
|
|
42
|
+
sessionLock,
|
|
40
43
|
debugEnabled,
|
|
41
44
|
widgetEnabled,
|
|
42
45
|
debugHistory,
|
|
@@ -53,12 +53,21 @@ export interface RoutingDecision {
|
|
|
53
53
|
isRuleMatched?: boolean;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Fixed model for a router profile for the lifetime of a session (or until profile switch). */
|
|
57
|
+
export interface SessionLock {
|
|
58
|
+
profile: string;
|
|
59
|
+
tier: RouterTier;
|
|
60
|
+
modelRef: string;
|
|
61
|
+
reasoning: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export interface RouterPersistedState {
|
|
57
65
|
enabled: boolean;
|
|
58
66
|
selectedProfile: string;
|
|
59
67
|
pinTier?: RouterTier;
|
|
60
68
|
pinByProfile?: RouterPinByProfile;
|
|
61
69
|
thinkingByProfile?: RouterThinkingByProfile;
|
|
70
|
+
sessionLock?: SessionLock;
|
|
62
71
|
debugEnabled?: boolean;
|
|
63
72
|
widgetEnabled?: boolean;
|
|
64
73
|
debugHistory?: RoutingDecision[];
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
RouterConfig,
|
|
5
5
|
RouterPinByProfile,
|
|
6
6
|
RouterThinkingByProfile,
|
|
7
|
+
SessionLock,
|
|
7
8
|
} from './types.js';
|
|
8
9
|
|
|
9
10
|
const getEffectiveThinking = (
|
|
@@ -63,6 +64,7 @@ export const updateStatus = (
|
|
|
63
64
|
accumulatedCost: number,
|
|
64
65
|
widgetEnabled: boolean,
|
|
65
66
|
currentConfig: RouterConfig,
|
|
67
|
+
sessionLock: SessionLock | undefined,
|
|
66
68
|
) => {
|
|
67
69
|
const activeRouterProfile = routerEnabled ? selectedProfile : undefined;
|
|
68
70
|
const statusProfile = selectedProfile;
|
|
@@ -81,7 +83,13 @@ export const updateStatus = (
|
|
|
81
83
|
activeRouterProfile,
|
|
82
84
|
lastDecision,
|
|
83
85
|
);
|
|
84
|
-
|
|
86
|
+
const locked =
|
|
87
|
+
sessionLock?.profile === activeRouterProfile
|
|
88
|
+
? sessionLock.modelRef
|
|
89
|
+
: lastDecision.targetLabel;
|
|
90
|
+
statusText = `router:${activeRouterProfile}${pinLabel} locked:${locked} thinking:${lastDecision.tier} (${effectiveThinking})`;
|
|
91
|
+
} else if (sessionLock?.profile === activeRouterProfile) {
|
|
92
|
+
statusText = `router:${activeRouterProfile}${pinLabel} locked:${sessionLock.modelRef} -> waiting`;
|
|
85
93
|
} else {
|
|
86
94
|
statusText = `router:${activeRouterProfile}${pinLabel} -> waiting`;
|
|
87
95
|
}
|
|
@@ -104,6 +112,11 @@ export const updateStatus = (
|
|
|
104
112
|
? ` / $${currentConfig.maxSessionBudget.toFixed(2)}`
|
|
105
113
|
: ''),
|
|
106
114
|
];
|
|
115
|
+
if (sessionLock && sessionLock.profile === statusProfile) {
|
|
116
|
+
widgetLines.push(
|
|
117
|
+
`Locked model: ${sessionLock.modelRef} (lock tier ${sessionLock.tier})`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
107
120
|
if (lastDecision && lastDecision.profile === statusProfile) {
|
|
108
121
|
const effectiveThinking = getEffectiveThinking(
|
|
109
122
|
thinkingByProfile,
|
|
@@ -114,8 +127,9 @@ export const updateStatus = (
|
|
|
114
127
|
const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
|
|
115
128
|
|
|
116
129
|
widgetLines.push(
|
|
117
|
-
`
|
|
130
|
+
`Thinking: ${lastDecision.tier}${flagsStr} (${effectiveThinking})`,
|
|
118
131
|
`Phase: ${lastDecision.phase}`,
|
|
132
|
+
`Delegate: ${lastDecision.targetProvider}/${lastDecision.targetModelId}`,
|
|
119
133
|
);
|
|
120
134
|
} else if (!routerEnabled && lastNonRouterModel) {
|
|
121
135
|
widgetLines.push(`Fallback: ${lastNonRouterModel}`);
|