@genee/omp-opsx-addon 0.8.0 â 0.10.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/README.md +440 -71
- package/index.ts +1106 -235
- package/lib/agent-defs.ts +80 -89
- package/lib/complexity-router.ts +336 -0
- package/lib/concurrency-control.ts +1246 -0
- package/lib/concurrency-multiproc.worker.ts +376 -0
- package/lib/concurrency-state.ts +397 -0
- package/lib/concurrency-tuner.ts +773 -0
- package/lib/concurrency-writer.ts +894 -0
- package/lib/continuity-guard.ts +117 -0
- package/lib/family-filter.ts +115 -2
- package/lib/model-roles.ts +43 -41
- package/lib/model-selector.ts +300 -36
- package/lib/model-speed.ts +189 -0
- package/lib/pick-model-render.ts +414 -0
- package/lib/pipe-core.ts +303 -93
- package/lib/pipe-push.ts +212 -26
- package/lib/provider-variants.ts +154 -0
- package/lib/reachability-refresh.ts +10 -2
- package/lib/selection-filters.ts +42 -1
- package/lib/stall-detector.ts +185 -0
- package/lib/system-prompt.ts +28 -11
- package/lib/tiers-data.ts +47 -5
- package/lib/tiers-updater.ts +51 -8
- package/lib/unified-config.ts +735 -68
- package/lib/usage-render.ts +57 -4
- package/lib/usage-widget.ts +18 -4
- package/package.json +1 -1
- package/skills/opsx-orchestration-protocol/SKILL.md +87 -0
package/index.ts
CHANGED
|
@@ -82,30 +82,71 @@ function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
|
|
|
82
82
|
getLogger().error('Failed to handle subagent event', { err });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext
|
|
86
|
-
import {
|
|
85
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@oh-my-pi/pi-coding-agent';
|
|
86
|
+
import { discoverAgents } from '@oh-my-pi/pi-coding-agent/task/discovery';
|
|
87
87
|
import type { Model, Api } from '@oh-my-pi/pi-catalog/types';
|
|
88
88
|
import type { UsageReport } from '@oh-my-pi/pi-ai';
|
|
89
89
|
import { createCachedScanner } from './lib/change-scanner.js';
|
|
90
|
-
import { installAgents } from './lib/agent-defs.js';
|
|
90
|
+
import { installAgents, SCOPE_FENCE_TAG } from './lib/agent-defs.js';
|
|
91
91
|
import {
|
|
92
92
|
applyRoleModelOverrides,
|
|
93
93
|
buildRoleOverridePlan,
|
|
94
94
|
dispatchModelHint,
|
|
95
95
|
frontmatterModel,
|
|
96
|
+
resolveOmpRoleTier,
|
|
96
97
|
ROLE_TO_OMP_ALIAS,
|
|
97
98
|
OMP_ROLE_TO_TIER,
|
|
98
99
|
ROLE_CAPABILITY,
|
|
99
100
|
type SkippedRole,
|
|
100
101
|
} from './lib/model-roles.js';
|
|
102
|
+
import {
|
|
103
|
+
annotationOfPicked,
|
|
104
|
+
coverageLabel,
|
|
105
|
+
formatAgentInventory,
|
|
106
|
+
formatCandidateOverview,
|
|
107
|
+
formatChangeLine,
|
|
108
|
+
formatFootnote,
|
|
109
|
+
formatRoleLines,
|
|
110
|
+
renderPickModelReport,
|
|
111
|
+
resolveSymbols,
|
|
112
|
+
roleKindLabel,
|
|
113
|
+
tierLabel,
|
|
114
|
+
} from './lib/pick-model-render.js';
|
|
101
115
|
import { buildStaticPrompt, buildDynamicPrompt } from './lib/system-prompt.js';
|
|
102
116
|
import { type ProviderHealth, canonicalizeProvider, listDetectableProviders, healthCacheKey } from './lib/usage-resolver.js';
|
|
103
|
-
import { readOpsxSettings, type ResolvedOpsxConfig, type
|
|
117
|
+
import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type AgentValue, OPSX_AGENTS } from './lib/unified-config.js';
|
|
118
|
+
import {
|
|
119
|
+
createConcurrencyController,
|
|
120
|
+
createNodeFsPort,
|
|
121
|
+
type ConcurrencyController,
|
|
122
|
+
type ConcurrencyLeaseFactory,
|
|
123
|
+
type ConcurrencyRedisPort,
|
|
124
|
+
type SettingsPort,
|
|
125
|
+
} from './lib/concurrency-control.js';
|
|
126
|
+
import { TUNER_DEFAULTS, type TunerOptions } from './lib/concurrency-tuner.js';
|
|
127
|
+
import { nodeWriterFileSystem } from './lib/concurrency-writer.js';
|
|
128
|
+
import { RedisClient } from './lib/usage-redis-client.js';
|
|
104
129
|
import { selectAllRoles, selectModel, mergePlanProviders, type SelectorResult } from './lib/model-selector.js';
|
|
130
|
+
import { resolveSpeedAware } from './lib/model-speed.js';
|
|
105
131
|
import { scoreModelWithProvider, type TierName } from './lib/model-tiers.js';
|
|
132
|
+
import {
|
|
133
|
+
applyBandOffset,
|
|
134
|
+
classifyTaskSafe,
|
|
135
|
+
type RoutingRuntime,
|
|
136
|
+
type TaskBand,
|
|
137
|
+
} from './lib/complexity-router.js';
|
|
138
|
+
import {
|
|
139
|
+
createStallRuntime,
|
|
140
|
+
evaluateStall,
|
|
141
|
+
recordToolCall,
|
|
142
|
+
rebuildFromEntries,
|
|
143
|
+
toolSignature,
|
|
144
|
+
type StallRuntime,
|
|
145
|
+
} from './lib/stall-detector.js';
|
|
146
|
+
import { decideGuard, pinPriorPick } from './lib/continuity-guard.js';
|
|
106
147
|
import { DIRECT_FETCHERS } from './lib/direct-fetchers.js';
|
|
107
148
|
import { createUsageWidget } from './lib/usage-widget.js';
|
|
108
|
-
import { renderUsageReports, type ConsumptionTrack } from './lib/usage-render.js';
|
|
149
|
+
import { renderUsageReports, type ConsumptionTrack, type ConcurrencyInlineView } from './lib/usage-render.js';
|
|
109
150
|
import { markExhausted, markAvailable, getExhaustedProviders } from './lib/provider-status.js';
|
|
110
151
|
import { registerPipeCore } from './lib/pipe-core.js';
|
|
111
152
|
import { registerPipePush } from './lib/pipe-push.js';
|
|
@@ -134,7 +175,6 @@ import {
|
|
|
134
175
|
import { updateTiers, autoRefreshTiersIfStale } from './lib/tiers-updater.js';
|
|
135
176
|
import { appendSample, getConsumptionSamples, RING_SIZE } from './lib/usage-sampler.js';
|
|
136
177
|
import { buildBalanceEstimates, type UsageBucket } from './lib/usage-estimator.js';
|
|
137
|
-
import { matchDataEntry, TIERS_DATA_PATH, isTiersDataLoaded, tiersDataAge } from './lib/tiers-data.js';
|
|
138
178
|
import {
|
|
139
179
|
loadBlockedModels,
|
|
140
180
|
persistBlockedModels,
|
|
@@ -144,21 +184,102 @@ import { refreshReachability } from './lib/reachability-refresh.js';
|
|
|
144
184
|
import { loadAvailableByProvider } from './lib/reachability-refresh.js';
|
|
145
185
|
import { filterSelectableModels, reachabilityFingerprint } from './lib/selection-filters.js';
|
|
146
186
|
import { parseAutoModelArgs, CHINA_EXCLUDE_FAMILIES, listFamilies, listProviders } from './lib/family-filter.js';
|
|
187
|
+
import {
|
|
188
|
+
REGION_VARIANT_GROUPS,
|
|
189
|
+
resolveVariantCollapse,
|
|
190
|
+
variantCollapseAnnotation,
|
|
191
|
+
collapseUsageView,
|
|
192
|
+
providerSurvivesAllowlist,
|
|
193
|
+
type VariantCollapseResult,
|
|
194
|
+
} from './lib/provider-variants.js';
|
|
147
195
|
import { hasRateLimitError, hasRegionError, buildErrorDiagnosis, exclusionReasonStats } from './lib/error-scan.js';
|
|
148
|
-
import { applyEditVariantPin } from './lib/edit-variant-pin.js';
|
|
196
|
+
import { applyEditVariantPin, resolveGlobalConfigPath } from './lib/edit-variant-pin.js';
|
|
149
197
|
import { setOpsxLogger, getLogger } from './lib/logger.js';
|
|
198
|
+
import { homedir } from 'node:os';
|
|
199
|
+
import { join } from 'node:path';
|
|
200
|
+
import { YAML } from 'bun';
|
|
150
201
|
type Role = 'coder' | 'reviewer' | 'planner' | 'proposal-reviewer';
|
|
151
202
|
|
|
152
203
|
/** Session-scoped selector constraint for `/pick-model <selector> [--china]`. */
|
|
153
204
|
export interface FamilyConstraint {
|
|
154
205
|
selectors: string[] | null;
|
|
155
206
|
excludeSelectors: string[];
|
|
207
|
+
/** Canonical modelRole ids the constraint narrows; null/absent = global (all roles). */
|
|
208
|
+
roles?: string[] | null;
|
|
156
209
|
}
|
|
157
210
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Stable fingerprint of the active selector constraint (design Decision 3/9;
|
|
213
|
+
* pick-model-role-scope: roles participate so globalâscoped switches and scope
|
|
214
|
+
* changes invalidate the selection health cache). Exported for tests â the
|
|
215
|
+
* fingerprint IS the documented cache-invalidation contract.
|
|
216
|
+
*/
|
|
217
|
+
export function familyConstraintFingerprint(fc: FamilyConstraint | null): string {
|
|
160
218
|
if (!fc) return 'none';
|
|
161
|
-
return `fam:${fc.selectors ? fc.selectors.join(',') : '*'}|excl:${fc.excludeSelectors.join(',')}`;
|
|
219
|
+
return `fam:${fc.selectors ? fc.selectors.join(',') : '*'}|excl:${fc.excludeSelectors.join(',')}|roles:${fc.roles ? fc.roles.join(',') : '*'}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Fingerprint of the regional-variant collapse set (china-config). Exported
|
|
224
|
+
* for tests â same contract as familyConstraintFingerprint: the segment is the
|
|
225
|
+
* documented cache-invalidation channel for auth-state flips (re-login can
|
|
226
|
+
* change which side of a variant pair collapses, so the set must participate
|
|
227
|
+
* in the selection health key).
|
|
228
|
+
*/
|
|
229
|
+
export function variantCollapseFingerprint(collapsed: ReadonlySet<string>): string {
|
|
230
|
+
return `variants:${[...collapsed].sort().join(',')}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Pre-resolve the API keys of the variant-group members present in `present`
|
|
235
|
+
* (â¤2 awaits) into the sync key view `resolveVariantCollapse` gates on. A
|
|
236
|
+
* missing getter, a rejected/throwing lookup, or an empty key all land as
|
|
237
|
+
* `undefined` â the gate stays conservative (both doors coexist).
|
|
238
|
+
*/
|
|
239
|
+
async function resolveVariantKeyLookup(
|
|
240
|
+
present: ReadonlySet<string>,
|
|
241
|
+
getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
|
|
242
|
+
): Promise<(provider: string) => string | undefined> {
|
|
243
|
+
const keys = new Map<string, string | undefined>();
|
|
244
|
+
for (const group of REGION_VARIANT_GROUPS) {
|
|
245
|
+
for (const member of [group.domestic, group.intl]) {
|
|
246
|
+
if (!present.has(member) || keys.has(member)) continue;
|
|
247
|
+
try {
|
|
248
|
+
keys.set(member, (await getApiKey?.(member)) ?? undefined);
|
|
249
|
+
} catch {
|
|
250
|
+
keys.set(member, undefined);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return (provider) => keys.get(provider);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Scope roles â tier slots (pick-model-role-scope D3). Resolved with the same
|
|
259
|
+
* effective mapping batch-all-roles uses (`model_role_tiers` override + the
|
|
260
|
+
* built-in default table). `vision` contributes no slot (its scope semantics
|
|
261
|
+
* gate the capability candidate set instead); a role resolving to the `skip`
|
|
262
|
+
* sentinel throws ScopedRoleSkipError â a skip role sits on no tier slot, so a
|
|
263
|
+
* pool constraint on it is inexpressible.
|
|
264
|
+
*/
|
|
265
|
+
export class ScopedRoleSkipError extends Error {
|
|
266
|
+
constructor(readonly role: string) {
|
|
267
|
+
super(`role åŽåæĄįŽ "${role}" įæææĄŖäŊä¸ē skipīŧmodel_role_tiersīŧīŧæ æŗå¯šå
ļæŊå æą įēĻæ`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function scopedTierSlots(
|
|
272
|
+
roles: readonly string[] | null | undefined,
|
|
273
|
+
ompRoleTiers: Record<string, TierName | 'skip'>,
|
|
274
|
+
): Set<TierName> {
|
|
275
|
+
const slots = new Set<TierName>();
|
|
276
|
+
for (const role of roles ?? []) {
|
|
277
|
+
const tier = resolveOmpRoleTier(role, ompRoleTiers);
|
|
278
|
+
if (tier === 'skip') throw new ScopedRoleSkipError(role);
|
|
279
|
+
if (role === 'vision') continue;
|
|
280
|
+
slots.add(tier);
|
|
281
|
+
}
|
|
282
|
+
return slots;
|
|
162
283
|
}
|
|
163
284
|
|
|
164
285
|
/**
|
|
@@ -170,12 +291,13 @@ function selectionHealthKeyFor(
|
|
|
170
291
|
reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
|
|
171
292
|
fc: FamilyConstraint | null,
|
|
172
293
|
blocked: ReadonlySet<string>,
|
|
294
|
+
collapsed: ReadonlySet<string>,
|
|
173
295
|
): string {
|
|
174
296
|
return `${healthCacheKey(health)}|${reachabilityFingerprint({
|
|
175
297
|
unreachable: reach.unreachable,
|
|
176
298
|
availableByProvider: reach.availableByProvider,
|
|
177
299
|
blocked,
|
|
178
|
-
})}|${familyConstraintFingerprint(fc)}`;
|
|
300
|
+
})}|${familyConstraintFingerprint(fc)}|${variantCollapseFingerprint(collapsed)}`;
|
|
179
301
|
}
|
|
180
302
|
|
|
181
303
|
/** Suggested id segment for the ambiguity hint (design Decision 8). */
|
|
@@ -240,7 +362,7 @@ const AGENT_MAP: Record<Role, string> = {
|
|
|
240
362
|
'proposal-reviewer': 'proposal-reviewer',
|
|
241
363
|
};
|
|
242
364
|
function findRoleForModel(provider: string, modelId: string, sel: SelectionContext): Role | null {
|
|
243
|
-
for (const role of
|
|
365
|
+
for (const role of OPSX_AGENTS) {
|
|
244
366
|
const r = sel.selectorResults[role];
|
|
245
367
|
if (!r) continue;
|
|
246
368
|
if (r.allCandidates.some((c) => c.selector === `${provider}/${modelId}`)) {
|
|
@@ -253,21 +375,19 @@ function findRoleForModel(provider: string, modelId: string, sel: SelectionConte
|
|
|
253
375
|
export interface SelectionContext {
|
|
254
376
|
selectorResults: Record<Role, SelectorResult>;
|
|
255
377
|
roleModels: Record<Role, string | undefined>;
|
|
256
|
-
/** Display label for each role from config, e.g. "auto", "smol", "deepseek-v4-pro". */
|
|
257
|
-
roleConfigs: Record<Role, string | undefined>;
|
|
258
378
|
/** Parsed role value kind for each role (auto | omp-alias | model | provider-model). */
|
|
259
|
-
roleKind: Record<Role,
|
|
379
|
+
roleKind: Record<Role, AgentValue['kind']>;
|
|
260
380
|
/** Full-role session overlay payload written to `overrideModelRoles` (all known OMP roles). */
|
|
261
381
|
roleOverrides: Record<string, string>;
|
|
262
382
|
/** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
|
|
263
383
|
skippedRoles: SkippedRole[];
|
|
384
|
+
/** payg_last_resort the selection was computed with â drives the ååē fold annotation in choices markdown. Default true. */
|
|
385
|
+
paygLastResort?: boolean;
|
|
386
|
+
/** W4 routing: the task band this selection was computed with (undefined = routing runtime absent). */
|
|
387
|
+
routingBand?: TaskBand;
|
|
264
388
|
}
|
|
265
389
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
function roleValueToModelString(value: RoleValue): string | undefined {
|
|
390
|
+
function roleValueToModelString(value: AgentValue): string | undefined {
|
|
271
391
|
if (value.kind === 'auto') return undefined;
|
|
272
392
|
if (value.kind === 'omp-alias') return `@${value.alias}`;
|
|
273
393
|
if (value.kind === 'provider-model') return `${value.provider}/${value.modelId}`;
|
|
@@ -305,77 +425,155 @@ export function buildDispatchPrompt(role: Role, task: string, selection: Selecti
|
|
|
305
425
|
else parts.push(`蝎ᴿĨéčŋ task åˇĨå
ˇč°į¨ ${agent}īŧæ éæåŽ modelīŧæ¨Ąåæ role čĒå¨č§Ŗæīŧã`);
|
|
306
426
|
if (sel?.decision) parts.push(`åå : ${sel.decision}`);
|
|
307
427
|
parts.push('į´æĨį¨å
įŊŽåˇĨå
ˇåŽį°ã');
|
|
428
|
+
if (role === 'coder') parts.push(`${SCOPE_FENCE_TAG}䏿ŦäģģåĄæ å
ŗįåŧ常įļæīŧåĢäēēæĒæäē¤įæšå¨ãæ å
ŗå¤ąč´Ĩæĩč¯ãæ å
ŗčæäģļãåˇĨäŊæ įļæä¸éĸæä¸įŦĻīŧīŧæå°į¸å
ŗæ§æ ¸æĨīŧåĻ git statusãįĄŽčŽ¤čĸĢæšå¨æäģ￝åĻčŊ卿ŦæŦĄæšå¨éĸå
īŧä¸åéīŧå¤åŽä¸ēæ å
ŗå MUST NOT č°æĨæšå¨æĨæēãMUST NOT æååŊåäģģåĄīŧæšä¸ē莰åŊå莞ãåå
Ĩäē¤äģæčĻ SUMMARYãįģ§įģæ§čĄåŊåäģģåĄīŧįéģåĄīŧįŧēäžčĩ / åĨįēĻ卿 / æéä¸čļŗ / é፿ˇåŗįīŧäģįĢåŗ STATUS: blockedã`);
|
|
308
429
|
return `蝎äŊŋ፠task åˇĨå
ˇå§æ´žįģ ${agent}īŧ\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
|
|
309
430
|
}
|
|
310
431
|
|
|
311
|
-
/**
|
|
312
|
-
* All model roles the host currently knows (10 built-in + custom roles from
|
|
313
|
-
* cycleOrder/modelRoles/modelTags). Host settings can throw during early
|
|
314
|
-
* startup or partial init; fall back to the built-in set rather than
|
|
315
|
-
* aborting selection (later recomputes pick up custom roles once ready).
|
|
316
|
-
*/
|
|
317
|
-
function safeGetKnownRoleIds(settings: Settings | null | undefined): string[] {
|
|
318
|
-
if (!settings) return [...MODEL_ROLE_IDS];
|
|
319
|
-
try {
|
|
320
|
-
return getKnownRoleIds(settings);
|
|
321
|
-
} catch (err) {
|
|
322
|
-
getLogger().warn(`[omp-opsx-addon] getKnownRoleIds failed; using built-in model roles only: ${err instanceof Error ? err.message : String(err)}`);
|
|
323
|
-
return [...MODEL_ROLE_IDS];
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
432
|
function computeSelectionSync(
|
|
328
433
|
config: ResolvedOpsxConfig,
|
|
329
434
|
models: Model<Api>[] | null,
|
|
330
435
|
health: Map<string, ProviderHealth> | null,
|
|
331
|
-
knownRoleIds: string[],
|
|
332
436
|
regionBlockedModels?: Set<string>,
|
|
333
437
|
unreachableProviders?: Set<string>,
|
|
334
438
|
availableByProvider?: Map<string, string[] | null>,
|
|
335
439
|
familyConstraint?: FamilyConstraint | null,
|
|
440
|
+
collapsedProviders?: ReadonlySet<string>,
|
|
441
|
+
onWarn?: (msg: string) => void,
|
|
442
|
+
/** W4 routing runtime (design D8.5); undefined = all switches off + balanced â byte-identical legacy path. */
|
|
443
|
+
routing?: RoutingRuntime,
|
|
336
444
|
): SelectionContext {
|
|
337
|
-
const
|
|
445
|
+
const fc = familyConstraint ?? null;
|
|
446
|
+
// pick-model-role-scope: pool split. basePool = reachability/provider
|
|
447
|
+
// filtering plus account-level regional-variant dedup (china-config: the
|
|
448
|
+
// collapse set is resolved at the call sites and narrows EVERY slot,
|
|
449
|
+
// scoped or not â account-level, orthogonal to constraints);
|
|
450
|
+
// filteredModels = basePool further narrowed by the selector filter and
|
|
451
|
+
// the effective china exclusion (explicit `--china` constraint first, else
|
|
452
|
+
// the `china: true` config default). Global constraints feed every slot
|
|
453
|
+
// from the single filtered pool (status-quo shape); scoped constraints
|
|
454
|
+
// only the slots in scope â everything else draws basePool, which
|
|
455
|
+
// deliberately KEEPS china families (role-scope contract: out-of-scope
|
|
456
|
+
// slots never inherit the standing exclusion).
|
|
457
|
+
const scopeRoles = fc?.roles && fc.roles.length > 0 ? fc.roles : null;
|
|
458
|
+
const scopedSlots = scopedTierSlots(scopeRoles, config.ompRoleTiers);
|
|
459
|
+
// provider-auto-targets: config-static per-provider narrowing at the pool
|
|
460
|
+
// layer â every downstream slot (tierPicks / vision / role pools) inherits it.
|
|
461
|
+
const providerModelMap = Object.keys(config.providerModels).length > 0
|
|
462
|
+
? new Map(Object.entries(config.providerModels))
|
|
463
|
+
: undefined;
|
|
464
|
+
const providerModelWipes = new Set<string>();
|
|
465
|
+
const basePool = models
|
|
338
466
|
? filterSelectableModels(models, {
|
|
339
467
|
blockedSelectors: regionBlockedModels,
|
|
340
468
|
unreachableProviders,
|
|
341
|
-
excludedProviders: config.excluded_providers,
|
|
469
|
+
excludedProviders: [...config.excluded_providers, ...(collapsedProviders ?? [])],
|
|
342
470
|
availableByProvider,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
familyConstraint?.excludeSelectors && familyConstraint.excludeSelectors.length > 0
|
|
346
|
-
? familyConstraint.excludeSelectors
|
|
347
|
-
: undefined,
|
|
471
|
+
providerModels: providerModelMap,
|
|
472
|
+
zeroHitProviders: providerModelWipes,
|
|
348
473
|
})
|
|
349
474
|
: models;
|
|
475
|
+
if (onWarn) {
|
|
476
|
+
for (const p of providerModelWipes) {
|
|
477
|
+
onWarn(`[omp-opsx-addon] provider_models.${p}: æļįĒéļåŊä¸īŧåéå
¨é¨æĒåŊ䏿¨ĄåŧīŧæŖæĨæ¨Ąåŧæŧ忝åĻä¸ catalog id ä¸č´īŧ`);
|
|
478
|
+
}
|
|
479
|
+
// Sentinel id drift (catalog renamed its auto entry): marking silently
|
|
480
|
+
// falls back to heuristic scoring â surface it instead of staying quiet.
|
|
481
|
+
for (const [p, id] of Object.entries(config.providerAuto)) {
|
|
482
|
+
const inPool = basePool?.some((m) => canonicalizeProvider(m.provider) === p);
|
|
483
|
+
if (!inPool) continue;
|
|
484
|
+
const hit = basePool?.some((m) => canonicalizeProvider(m.provider) === p && m.id.toLowerCase() === id.toLowerCase());
|
|
485
|
+
if (!hit) onWarn(`[omp-opsx-addon] provider_auto.${p}: åŖ°æįå¨å
ĩ id "${id}" ä¸å¨ ${p} įåæ´ģåéä¸īŧcatalog id 厞æŧį§ģīŧæ čŽ°å¤ąæīŧåčŊæŽéč¯åīŧ`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// åŊä줿žåŧ > é
įŊŽįŧēį: a constraint carrying excludeSelectors wins; the
|
|
489
|
+
// config default only fills in when the constraint carries none. Invariant:
|
|
490
|
+
// excludeSelectors has exactly one write site (parsed.china â
|
|
491
|
+
// CHINA_EXCLUDE_FAMILIES in the selector branch), so non-empty ⥠explicit.
|
|
492
|
+
const effectiveExclude =
|
|
493
|
+
fc?.excludeSelectors && fc.excludeSelectors.length > 0
|
|
494
|
+
? fc.excludeSelectors
|
|
495
|
+
: config.china
|
|
496
|
+
? CHINA_EXCLUDE_FAMILIES
|
|
497
|
+
: undefined;
|
|
498
|
+
const filteredModels = basePool
|
|
499
|
+
? filterSelectableModels(basePool, {
|
|
500
|
+
selectors: fc?.selectors ?? undefined,
|
|
501
|
+
excludeSelectors: effectiveExclude,
|
|
502
|
+
})
|
|
503
|
+
: basePool;
|
|
504
|
+
// Pool a slot draws from: global constraints (roles null) constrain every
|
|
505
|
+
// slot; scoped constraints only the slots the scope roles map onto.
|
|
506
|
+
const poolForSlot = (slot: TierName): Model<Api>[] | null =>
|
|
507
|
+
scopeRoles === null || scopedSlots.has(slot) ? filteredModels : basePool;
|
|
350
508
|
const selectorResults = {} as Record<Role, SelectorResult>;
|
|
351
509
|
const roleModels = {} as Record<Role, string | undefined>;
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
510
|
+
const roleKind = {} as Record<Role, AgentValue['kind']>;
|
|
511
|
+
const planSet = mergePlanProviders(config.planProviders, config.planProvidersRemove);
|
|
512
|
+
// Speed-aware selection: undefined when disabled â zero DB IO, and the
|
|
513
|
+
// selector keeps the frozen W1 order byte-for-byte.
|
|
514
|
+
const speedAware = resolveSpeedAware(config.speedAware);
|
|
515
|
+
// ââ W4 difficulty routing (design D3/D5) ââââââââââââââââââââââ
|
|
516
|
+
// `routing === undefined` keeps every map/value at its legacy identity â
|
|
517
|
+
// the zero-drift structure is "not entering" the new paths, not an
|
|
518
|
+
// equivalent re-derivation. Explicit command paths KEEP the session-band
|
|
519
|
+
// offsets (design D5: æ˛ŋį¨åŊåäŧč¯įē§ band) and skip only guard pinning and
|
|
520
|
+
// the band annotation (æžåŧæäģ¤ > åŽé¨īŧæžåŧ莝åžä¸åæ°åįąģ).
|
|
521
|
+
// Offsets are SELECTOR-TARGET pre-corrections only: the roleâslot mapping
|
|
522
|
+
// (buildRoleOverridePlan's input) stays structurally untouched â smol
|
|
523
|
+
// always consumes the coder pick, slow the top supplemental, so shifted
|
|
524
|
+
// targets flow through the existing slots.
|
|
525
|
+
// Selector-side offsets key on the OMP write-set alias (smol/default/
|
|
526
|
+
// slow/vision); opsx roles translate via ROLE_TO_OMP_ALIAS.
|
|
527
|
+
const routedTierFor = (role: string, ompRole: string | undefined, base: TierName): TierName => {
|
|
528
|
+
if (!routing || !ompRole) return base;
|
|
529
|
+
// The per-role map carries only this role's base tier, so a `skip`
|
|
530
|
+
// sentinel can never appear here â but the offset table's value type
|
|
531
|
+
// admits it, and falling back to the base keeps that safe.
|
|
532
|
+
const routed = applyBandOffset({ [ompRole]: base }, routing.band, { stallActive: routing.stallActive });
|
|
533
|
+
const value = routed[ompRole];
|
|
534
|
+
return value === 'skip' || value === undefined ? base : value;
|
|
535
|
+
};
|
|
536
|
+
// Data-layer annotations (design D9): `band=<band>` when routing drives
|
|
537
|
+
// the recompute, `stall+1` on the roles aliased to default while stall is
|
|
538
|
+
// active. Explicit paths suppress the band segment (no fresh
|
|
539
|
+
// classification).
|
|
540
|
+
const noteFor = (role: string): string | undefined => {
|
|
541
|
+
if (!routing) return undefined;
|
|
542
|
+
const parts: string[] = [];
|
|
543
|
+
if (!routing.explicit && config.difficultyRouting.enabled) parts.push(`band=${routing.band}`);
|
|
544
|
+
if (routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default') parts.push('stall+1');
|
|
545
|
+
return parts.length > 0 ? parts.join(', ') : undefined;
|
|
546
|
+
};
|
|
547
|
+
for (const role of OPSX_AGENTS) {
|
|
548
|
+
const value = config.agents[role];
|
|
357
549
|
roleKind[role] = value.kind;
|
|
358
|
-
// Config label shown in /pick-model choices markdown (`č§č˛ (label)`):
|
|
359
|
-
// auto roles display the OMP tier-role alias; pinned roles the pin.
|
|
360
|
-
roleConfigs[role] = value.kind === 'auto'
|
|
361
|
-
? ROLE_TO_OMP_ALIAS[role]
|
|
362
|
-
: (roleValueToModelString(value) ?? '?');
|
|
363
550
|
const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
|
|
364
551
|
if (value.kind === 'auto') {
|
|
365
|
-
|
|
552
|
+
// coder's pick feeds the mid slot, the other three feed high â
|
|
553
|
+
// the same consumed-slot mapping buildEffectiveConfig and the
|
|
554
|
+
// tierPicks assembly below use.
|
|
555
|
+
const pool = poolForSlot(role === 'coder' ? 'mid' : 'high');
|
|
556
|
+
if (pool && pool.length > 0) {
|
|
366
557
|
const ompRole = ROLE_TO_OMP_ALIAS[role];
|
|
367
|
-
const tier = (config.
|
|
558
|
+
const tier = routedTierFor(role, ompRole, (config.agentTiers[role]
|
|
368
559
|
?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
|
|
369
|
-
?? 'mid') as TierName;
|
|
370
|
-
const roleTiers = { ...config.
|
|
560
|
+
?? 'mid') as TierName);
|
|
561
|
+
const roleTiers = { ...config.agentTiers, [role]: tier };
|
|
371
562
|
const result = selectAllRoles(
|
|
372
563
|
[role],
|
|
373
|
-
|
|
564
|
+
pool,
|
|
374
565
|
roleTiers,
|
|
375
566
|
config.tierOverrides,
|
|
376
567
|
health ?? undefined,
|
|
377
568
|
config.modelAllowlist,
|
|
378
569
|
planSet,
|
|
570
|
+
undefined,
|
|
571
|
+
config.paygLastResort,
|
|
572
|
+
config.providerAuto,
|
|
573
|
+
config.providerAutoTiers,
|
|
574
|
+
speedAware,
|
|
575
|
+
routing?.qualityPreference,
|
|
576
|
+
noteFor(role),
|
|
379
577
|
)[role];
|
|
380
578
|
selectorResults[role] = result;
|
|
381
579
|
roleModels[role] = result.picked ? alias : undefined;
|
|
@@ -397,123 +595,118 @@ function computeSelectionSync(
|
|
|
397
595
|
roleModels[role] = modelString?.includes('/') ? modelString : undefined;
|
|
398
596
|
}
|
|
399
597
|
}
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
598
|
+
// ââ W4 continuity guard (design D5) âââââââââââââââââââââââââââ
|
|
599
|
+
// Pin viable incumbents on auto recomputes â the pin outranks every
|
|
600
|
+
// tie-break; a switch needs a strict band upgrade at sufficient
|
|
601
|
+
// confidence, a stall escalation, or an incumbent hard-filtered out of
|
|
602
|
+
// the pool. Runs BEFORE the overlay plan so pinned picks flow into
|
|
603
|
+
// roleOverrides; explicit paths bypass (æžåŧæäģ¤ > åŽé¨).
|
|
604
|
+
if (routing?.guard && !routing.explicit) {
|
|
605
|
+
const incumbentResults = routing.guard.incumbent?.selectorResults ?? null;
|
|
606
|
+
for (const role of OPSX_AGENTS) {
|
|
607
|
+
const prev = incumbentResults?.[role];
|
|
608
|
+
if (!prev?.picked) continue;
|
|
609
|
+
const cur = selectorResults[role];
|
|
610
|
+
const picked = prev.picked;
|
|
611
|
+
// Viability = the incumbent's selector still sits in the fresh
|
|
612
|
+
// candidate pool (zero registry queries â pool membership only).
|
|
613
|
+
const viable = cur.candidates.some((c) => c.model.provider === picked.provider && c.model.id === picked.id);
|
|
614
|
+
// Stall escalates the `default` alias only (reviewer/planner/
|
|
615
|
+
// proposal-reviewer); coder maps to smol and stays sticky.
|
|
616
|
+
const outcome = decideGuard({
|
|
617
|
+
enabled: true,
|
|
618
|
+
weight: routing.guard.weight,
|
|
619
|
+
explicit: false,
|
|
620
|
+
incumbent: prev,
|
|
621
|
+
incumbentViable: viable,
|
|
622
|
+
newBand: routing.band,
|
|
623
|
+
incumbentBand: routing.guard.priorBand ?? null,
|
|
624
|
+
confidence: routing.confidence,
|
|
625
|
+
stallEscalated: routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default',
|
|
626
|
+
});
|
|
627
|
+
if (outcome.decision === 'pin' && outcome.pinned) {
|
|
628
|
+
selectorResults[role] = pinPriorPick(outcome.pinned, routing.band);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// change: pick-model-ux â minimal write-set session overlay {smol, default,
|
|
633
|
+
// slow, vision}: mid/high reuse the opsx picks (coder/reviewer), top is the
|
|
634
|
+
// sole supplemental pick (slow), vision picks from image-capable candidates
|
|
635
|
+
// only. Roles outside the set resolve through OMP native chains. See
|
|
636
|
+
// lib/model-roles.ts design D5.
|
|
405
637
|
let roleOverrides: Record<string, string> = {};
|
|
406
638
|
let skippedRoles: SkippedRole[] = [];
|
|
407
|
-
if (
|
|
639
|
+
if (basePool && basePool.length > 0) {
|
|
408
640
|
const pickString = (r: SelectorResult): string | undefined =>
|
|
409
641
|
r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
|
|
410
642
|
const tierPicks: Partial<Record<TierName, string | undefined>> = {
|
|
643
|
+
// Guard pinning ran above: a pinned pick replaces the role result
|
|
644
|
+
// and therefore feeds the overlay payload (T4: application stays
|
|
645
|
+
// at the existing apply points â this only refreshes the cache).
|
|
411
646
|
mid: pickString(selectorResults.coder),
|
|
412
647
|
high: pickString(selectorResults.reviewer),
|
|
413
648
|
};
|
|
414
|
-
|
|
415
|
-
|
|
649
|
+
const synthRole = '__tier_top';
|
|
650
|
+
const topPool = poolForSlot('top');
|
|
651
|
+
const topSlotTier: TierName = routing
|
|
652
|
+
? applyBandOffset({ slow: 'top' }, routing.band, { stallActive: routing.stallActive }).slow as TierName
|
|
653
|
+
: 'top';
|
|
654
|
+
if (topPool && topPool.length > 0) {
|
|
416
655
|
const result = selectModel({
|
|
417
656
|
role: synthRole,
|
|
418
|
-
models:
|
|
419
|
-
|
|
657
|
+
models: topPool,
|
|
658
|
+
// This slot feeds the write-set `slow` role: its target shifts
|
|
659
|
+
// with the band (topâ1 under simple etc., design D3), while the
|
|
660
|
+
// roleâslot mapping itself stays fixed.
|
|
661
|
+
roleTiers: { [synthRole]: topSlotTier },
|
|
420
662
|
tierOverrides: config.tierOverrides,
|
|
421
663
|
health: health ?? undefined,
|
|
422
664
|
allowlist: config.modelAllowlist,
|
|
423
665
|
planProviders: planSet,
|
|
666
|
+
paygLastResort: config.paygLastResort,
|
|
667
|
+
providerAuto: config.providerAuto,
|
|
668
|
+
providerAutoTiers: config.providerAutoTiers,
|
|
669
|
+
speedAware,
|
|
670
|
+
qualityPreference: routing?.qualityPreference,
|
|
424
671
|
});
|
|
425
|
-
tierPicks
|
|
672
|
+
tierPicks.top = pickString(result);
|
|
426
673
|
}
|
|
427
674
|
const capabilityPicks: Record<string, string | undefined> = {};
|
|
428
|
-
|
|
675
|
+
// vision scope gates its capability candidate set only â it never
|
|
676
|
+
// contributes a tier slot.
|
|
677
|
+
const visionSource = scopeRoles?.includes('vision') ? filteredModels : basePool;
|
|
678
|
+
const visionModels = (visionSource ?? []).filter((m) => ROLE_CAPABILITY.vision(m));
|
|
429
679
|
if (visionModels.length > 0) {
|
|
430
680
|
const visionResult = selectModel({
|
|
431
681
|
role: 'vision',
|
|
432
682
|
models: visionModels,
|
|
433
|
-
|
|
683
|
+
// Write-set member: band offset shifts the in-pool preference
|
|
684
|
+
// (capability filter already ran â no incapable-model risk).
|
|
685
|
+
roleTiers: routing
|
|
686
|
+
? applyBandOffset({ vision: 'high' } as Record<string, TierName>, routing.band, { stallActive: routing.stallActive }) as Record<string, TierName>
|
|
687
|
+
: { vision: 'high' },
|
|
434
688
|
tierOverrides: config.tierOverrides,
|
|
435
689
|
health: health ?? undefined,
|
|
436
690
|
allowlist: config.modelAllowlist,
|
|
437
691
|
planProviders: planSet,
|
|
692
|
+
paygLastResort: config.paygLastResort,
|
|
693
|
+
providerAuto: config.providerAuto,
|
|
694
|
+
providerAutoTiers: config.providerAutoTiers,
|
|
695
|
+
speedAware,
|
|
696
|
+
qualityPreference: routing?.qualityPreference,
|
|
438
697
|
});
|
|
439
698
|
capabilityPicks.vision = pickString(visionResult);
|
|
440
699
|
}
|
|
441
|
-
const plan = buildRoleOverridePlan(
|
|
442
|
-
{ knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
|
|
443
|
-
(m) => getLogger().warn(m),
|
|
444
|
-
);
|
|
700
|
+
const plan = buildRoleOverridePlan({ ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks });
|
|
445
701
|
roleOverrides = plan.overrides;
|
|
446
702
|
skippedRoles = plan.skipped;
|
|
447
703
|
}
|
|
448
|
-
return { selectorResults, roleModels,
|
|
449
|
-
}
|
|
450
|
-
function formatModelChoicesMarkdown(sel: SelectionContext, suffix: string = '', primaryModel?: Model<Api>): string {
|
|
451
|
-
const rows: string[] = [];
|
|
452
|
-
let totalModelCount = 0;
|
|
453
|
-
if (primaryModel) {
|
|
454
|
-
const score = scoreModelWithProvider(primaryModel.provider, primaryModel.id, primaryModel.name, undefined);
|
|
455
|
-
rows.push(
|
|
456
|
-
`| đ **ä¸ģäŧč¯** | **${primaryModel.provider}/${primaryModel.id}** | ${score.tier} | åŊåæ´ģ莿¨Ąå (tier=${score.tier}) |`,
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
for (const role of OPSX_ROLES) {
|
|
460
|
-
const r = sel.selectorResults[role];
|
|
461
|
-
if (!r) continue;
|
|
462
|
-
totalModelCount += r.allCandidates.length;
|
|
463
|
-
const icon = r.picked ? 'â
' : 'â';
|
|
464
|
-
const configLabel = sel.roleConfigs[role];
|
|
465
|
-
const displayRole = configLabel ? `${role} (${configLabel})` : role;
|
|
466
|
-
const model = r.picked ? `${r.picked.provider}/${r.picked.id}` : (sel.roleModels[role] ?? '(none)');
|
|
467
|
-
const detail = r.pickedReason ?? r.decision;
|
|
468
|
-
rows.push(`| ${icon} **${displayRole}** | **${model}** | ${r.expectedTier} | ${detail} |`);
|
|
469
|
-
}
|
|
470
|
-
if (rows.length === 0) return `*æ¨ĄåéæŠ${suffix}: no data*`;
|
|
471
|
-
return [
|
|
472
|
-
'| æēčŊäŊ | æ¨Ąå | æææĄŖäŊ | č¯Ļæ
|',
|
|
473
|
-
'|---|---|---|---|',
|
|
474
|
-
...rows,
|
|
475
|
-
'',
|
|
476
|
-
`*${totalModelCount} ä¸Ē model åä¸č¯å ¡ \`/pick-model refresh\` åŧēåļéé*`,
|
|
477
|
-
].join('\n');
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** Format per-family per-tier model choices as a markdown table. */
|
|
481
|
-
function formatFamilyTierMarkdown(sel: SelectionContext): string {
|
|
482
|
-
// Group (provider, tier) â best model by reputation
|
|
483
|
-
const byFamily = new Map<string, Map<string, { modelId: string; rep: number; rank: number }>>();
|
|
484
|
-
for (const role of OPSX_ROLES) {
|
|
485
|
-
const r = sel.selectorResults[role];
|
|
486
|
-
if (!r) continue;
|
|
487
|
-
for (const c of r.allCandidates) {
|
|
488
|
-
if (c.exhausted) continue;
|
|
489
|
-
const fam = c.model.provider;
|
|
490
|
-
const tier = c.tier;
|
|
491
|
-
if (!byFamily.has(fam)) byFamily.set(fam, new Map());
|
|
492
|
-
const tierMap = byFamily.get(fam)!;
|
|
493
|
-
const existing = tierMap.get(tier);
|
|
494
|
-
if (!existing || c.reputation > existing.rep || (c.reputation === existing.rep && c.rank > existing.rank)) {
|
|
495
|
-
tierMap.set(tier, { modelId: c.model.id, rep: c.reputation, rank: c.rank });
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const rows: string[] = [];
|
|
501
|
-
const families = [...byFamily.keys()].sort();
|
|
502
|
-
for (const fam of families) {
|
|
503
|
-
const tierMap = byFamily.get(fam)!;
|
|
504
|
-
const parts: string[] = [];
|
|
505
|
-
for (const tier of ['top', 'high', 'mid', 'low', 'tiny']) {
|
|
506
|
-
const m = tierMap.get(tier);
|
|
507
|
-
if (!m) continue;
|
|
508
|
-
const shortId = m.modelId.length > 35 ? m.modelId.slice(0, 32) + '...' : m.modelId;
|
|
509
|
-
parts.push(`${tier}â${shortId}`);
|
|
510
|
-
}
|
|
511
|
-
if (parts.length === 0) continue;
|
|
512
|
-
rows.push(`**${fam}**: ${parts.join(' ')}`);
|
|
513
|
-
}
|
|
514
|
-
if (rows.length === 0) return '*No models available*';
|
|
515
|
-
return rows.join('\n');
|
|
704
|
+
return { selectorResults, roleModels, roleKind, roleOverrides, skippedRoles, paygLastResort: config.paygLastResort, routingBand: routing?.band };
|
|
516
705
|
}
|
|
706
|
+
// change: pick-model-ux â choices/switch/refresh/reset output rendering lives
|
|
707
|
+
// in lib/pick-model-render.ts (shared compact block renderer); the former
|
|
708
|
+
// formatModelChoicesMarkdown / formatFamilyTierMarkdown markdown tables were
|
|
709
|
+
// migrated there.
|
|
517
710
|
|
|
518
711
|
// ââ process-wide usage recorder singleton ââââââââââââââââââââââââââ
|
|
519
712
|
// This IS a process-wide singleton: the harness rebinds the same evaluated
|
|
@@ -540,8 +733,174 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
|
|
|
540
733
|
onDelta: mirrorSessionDelta,
|
|
541
734
|
});
|
|
542
735
|
|
|
736
|
+
// ââ adaptive provider concurrency (process-wide owner) ââââââââââââââ
|
|
737
|
+
// Ownership model (the same P0 invariant as plugins/omp-whip/index.ts): the
|
|
738
|
+
// host re-runs this factory once per in-process subagent binding, so an owner
|
|
739
|
+
// kept in the closure would be claimed by EVERY binding â each starting its
|
|
740
|
+
// own control loop, lease and state file against the same config root. The
|
|
741
|
+
// owner therefore lives at module scope (shared by every binding in this
|
|
742
|
+
// process, and re-imported fresh by isolated workers), and the session id
|
|
743
|
+
// captured at claim time is the identity gate for `session_shutdown`: without
|
|
744
|
+
// it, a subagent exiting would tear down the root's loop and release its lease.
|
|
745
|
+
interface OwnedConcurrency {
|
|
746
|
+
/** Session id captured when this process claimed the control loop. */
|
|
747
|
+
sessionId: string;
|
|
748
|
+
controller: ConcurrencyController;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let ownedConcurrency: OwnedConcurrency | null = null;
|
|
752
|
+
|
|
753
|
+
// Process-wide resize-handler slot: the host re-runs the plugin factory per
|
|
754
|
+
// session binding WITHOUT re-evaluating the module graph, so a closure-level
|
|
755
|
+
// "previous handler" variable is per-instance â each binding would attach its
|
|
756
|
+
// own stdout resize listener and nobody could off the others (observed as
|
|
757
|
+
// MaxListenersExceededWarning: 11 resize listeners after enough bindings).
|
|
758
|
+
// Module scope gives every binding the same single slot.
|
|
759
|
+
let activeResizeHandler: (() => void) | null = null;
|
|
760
|
+
|
|
761
|
+
/** Test-only: reset the module-level control-loop owner (production never calls this). */
|
|
762
|
+
export function __resetOwnedConcurrencyForTests(): void {
|
|
763
|
+
ownedConcurrency = null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Test-only: read the module-level control-loop owner (production never calls this). */
|
|
767
|
+
export function __getOwnedConcurrencyForTests(): OwnedConcurrency | null {
|
|
768
|
+
return ownedConcurrency;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Test-only seam (mirrors usage-poller's `_set*ForTest`): overrides the
|
|
773
|
+
* transport the assembly injects into the control loop. `undefined` derives it
|
|
774
|
+
* from `redis_*` config (production), `null` forces the Redis-free path so the
|
|
775
|
+
* file lease / degradation behavior is testable without a local Redis.
|
|
776
|
+
*/
|
|
777
|
+
let concurrencyRedisPortForTests: ConcurrencyRedisPort | null | undefined;
|
|
778
|
+
export function _setConcurrencyRedisPortForTest(port: ConcurrencyRedisPort | null | undefined): void {
|
|
779
|
+
concurrencyRedisPortForTests = port;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Test-only seam: the home directory the assembly reads its **global**
|
|
784
|
+
* `opsx.yml` layer from (`<home>/.omp/agent/opsx.yml`). `undefined` = the real
|
|
785
|
+
* `homedir()` (production); a path makes the factory hermetic, so a suite that
|
|
786
|
+
* starts the loop cannot inherit whatever the developer happens to have set
|
|
787
|
+
* globally â `concurrency_enabled: false` there would otherwise silently disable
|
|
788
|
+
* every factory-level case.
|
|
789
|
+
*/
|
|
790
|
+
let opsxGlobalHomeForTests: string | undefined;
|
|
791
|
+
export function _setOpsxGlobalHomeForTest(homeDir: string | undefined): void {
|
|
792
|
+
opsxGlobalHomeForTests = homeDir;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Process-stable lease owner id: `pid` + process start time (design D5). */
|
|
796
|
+
const PROCESS_OWNER = `${process.pid}:${Math.floor(Date.now() - process.uptime() * 1000)}`;
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Keys declared under `providers.maxInFlightRequests` in the shared
|
|
800
|
+
* `config.yml`. Only key *presence* is read here â the writer owns value
|
|
801
|
+
* validation, so a key whose value is not a positive integer still enters the
|
|
802
|
+
* managed set (its baseline reads asãæ į°åŧãâ `concurrency_start`).
|
|
803
|
+
*/
|
|
804
|
+
function readLimitKeys(configPath: string): string[] {
|
|
805
|
+
let text: string | null;
|
|
806
|
+
try {
|
|
807
|
+
text = nodeWriterFileSystem.readFile(configPath);
|
|
808
|
+
} catch {
|
|
809
|
+
return [];
|
|
810
|
+
}
|
|
811
|
+
if (text === null) return [];
|
|
812
|
+
try {
|
|
813
|
+
const limits = (YAML.parse(text) as { providers?: { maxInFlightRequests?: unknown } } | null)
|
|
814
|
+
?.providers?.maxInFlightRequests;
|
|
815
|
+
if (!limits || typeof limits !== 'object' || Array.isArray(limits)) return [];
|
|
816
|
+
return Object.keys(limits as Record<string, unknown>);
|
|
817
|
+
} catch {
|
|
818
|
+
return [];
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Map the resolved `concurrency_*` config onto the tuner's injected knobs. */
|
|
823
|
+
function tunerOptionsFromConfig(cfg: ResolvedOpsxConfig): TunerOptions {
|
|
824
|
+
return {
|
|
825
|
+
floor: cfg.concurrency_floor,
|
|
826
|
+
ceiling: cfg.concurrency_ceiling,
|
|
827
|
+
start: cfg.concurrency_start,
|
|
828
|
+
upMs: cfg.concurrency_up_ms,
|
|
829
|
+
downMs: cfg.concurrency_down_ms,
|
|
830
|
+
k: cfg.concurrency_latency_k,
|
|
831
|
+
minSamples: cfg.concurrency_min_samples,
|
|
832
|
+
queueWaitHighMs: cfg.concurrency_queue_wait_high_ms,
|
|
833
|
+
queueWaitWindowMs: cfg.concurrency_queue_wait_window_ms,
|
|
834
|
+
evalIntervalMs: cfg.concurrency_eval_interval_ms,
|
|
835
|
+
decayAfterMs: cfg.concurrency_decay_after_ms,
|
|
836
|
+
ewmaAlpha: TUNER_DEFAULTS.ewmaAlpha,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Assembly seams for the control loop; production passes host/plugin values, tests inject. */
|
|
841
|
+
export interface ConcurrencyAssemblyDeps {
|
|
842
|
+
/** Config root `config.yml` the writer reads/merges/writes. */
|
|
843
|
+
configPath: string;
|
|
844
|
+
/** Agent dir the log reader / `model_perf` path derive from. */
|
|
845
|
+
agentDir?: string;
|
|
846
|
+
/** Host settings port for the hot-apply (`settings.override`). */
|
|
847
|
+
settings?: SettingsPort | null;
|
|
848
|
+
/** Cross-process â aggregation channel (`null` â local-only + warn). */
|
|
849
|
+
redis?: ConcurrencyRedisPort | null;
|
|
850
|
+
/** Warn sink (the host logger). */
|
|
851
|
+
warn: (message: string) => void;
|
|
852
|
+
/** Injected clock (tests). */
|
|
853
|
+
now?: () => number;
|
|
854
|
+
/** Lease owner id (defaults to this process's `pid:startTime`). */
|
|
855
|
+
owner?: string;
|
|
856
|
+
/** Managed-set override (tests). */
|
|
857
|
+
managedProviders?: readonly string[];
|
|
858
|
+
/** Lease factory seam (tests). */
|
|
859
|
+
leaseFactory?: ConcurrencyLeaseFactory;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Build the control loop for this config root, or `null` when it MUST NOT run:
|
|
864
|
+
* `concurrency_enabled: false` (specãé
įŊŽéĸä¸éįē§ã: no setting write, no lease
|
|
865
|
+
* competition, no state-file write) or no managed provider to control. The
|
|
866
|
+
* managed set is `concurrency_providers` âĒ the shared `providers.
|
|
867
|
+
* maxInFlightRequests` keys (specãåįŽĄéåã).
|
|
868
|
+
*
|
|
869
|
+
* Ports are wired here: the node fs port, the derived sqlite `model_perf` port
|
|
870
|
+
* and the file/Redis lease come from the controller's production defaults; the
|
|
871
|
+
* host settings `override` and the existing Redis client are injected.
|
|
872
|
+
*/
|
|
873
|
+
export function createConcurrencyControllerIfEnabled(
|
|
874
|
+
cfg: ResolvedOpsxConfig,
|
|
875
|
+
deps: ConcurrencyAssemblyDeps,
|
|
876
|
+
): ConcurrencyController | null {
|
|
877
|
+
if (!cfg.concurrency_enabled) return null;
|
|
878
|
+
const managedProviders = deps.managedProviders
|
|
879
|
+
?? resolveManagedProviders(cfg.concurrency_providers, readLimitKeys(deps.configPath));
|
|
880
|
+
if (managedProviders.length === 0) return null;
|
|
881
|
+
return createConcurrencyController({
|
|
882
|
+
configPath: deps.configPath,
|
|
883
|
+
managedProviders,
|
|
884
|
+
tuner: tunerOptionsFromConfig(cfg),
|
|
885
|
+
now: deps.now ?? (() => Date.now()),
|
|
886
|
+
fs: createNodeFsPort(),
|
|
887
|
+
owner: deps.owner ?? PROCESS_OWNER,
|
|
888
|
+
...(deps.agentDir ? { agentDir: deps.agentDir } : {}),
|
|
889
|
+
redis: deps.redis ?? null,
|
|
890
|
+
settings: deps.settings ?? null,
|
|
891
|
+
ceilingByProvider: cfg.concurrency_ceiling_by_provider,
|
|
892
|
+
warn: deps.warn,
|
|
893
|
+
leaseTtlMs: cfg.concurrency_lease_ttl_ms,
|
|
894
|
+
writeMinIntervalMs: cfg.concurrency_write_min_interval_ms,
|
|
895
|
+
...(deps.leaseFactory ? { leaseFactory: deps.leaseFactory } : {}),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
|
|
543
899
|
// ââ plugin entry ââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
544
900
|
|
|
901
|
+
/** Bounded backoff for session-start autoselect cold-start recovery (precedent: RESET_IMMINENT_MS). Not a config key. */
|
|
902
|
+
export const AUTOSELECT_RETRY_DELAYS_MS = [5_000, 15_000, 30_000] as const;
|
|
903
|
+
|
|
545
904
|
export default (pi: ExtensionAPI) => {
|
|
546
905
|
// Route plugin logging through the host logger (rolling file) instead of
|
|
547
906
|
// console.*, which would corrupt the TUI. Injected before any log path runs.
|
|
@@ -558,16 +917,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
558
917
|
applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
|
|
559
918
|
const directory = process.cwd();
|
|
560
919
|
const warn = (msg: string) => pi.logger.warn(msg);
|
|
561
|
-
|
|
562
|
-
// call so custom roles appearing after startup are covered on
|
|
563
|
-
// refresh/reset/selector/tick.
|
|
564
|
-
const currentKnownRoleIds = (): string[] => safeGetKnownRoleIds(pi.pi?.settings);
|
|
565
|
-
const opsxConfig = readOpsxSettings(directory, warn);
|
|
920
|
+
const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
|
|
566
921
|
// Static orchestration rules â built once, reused every turn for cache stability.
|
|
567
922
|
const STATIC_PROMPT = buildStaticPrompt();
|
|
568
923
|
let initialModel: { provider: string; id: string } | null = null;
|
|
569
|
-
// Active resize handler â tracked so each session_start can off the previous one.
|
|
570
|
-
let activeResizeHandler: (() => void) | null = null;
|
|
571
924
|
let selection: SelectionContext | null = null;
|
|
572
925
|
// 403 no longer auto-blocks; the set is always empty in the running session.
|
|
573
926
|
// A one-time startup migration clears stale blockedModels written by the old
|
|
@@ -585,28 +938,145 @@ export default (pi: ExtensionAPI) => {
|
|
|
585
938
|
// and `/pick-model reset` restores it to opsxConfig.
|
|
586
939
|
let familyConstraint: FamilyConstraint | null = null;
|
|
587
940
|
let effectiveConfig: ResolvedOpsxConfig = opsxConfig;
|
|
941
|
+
// Session-start autoselect once-flag. Factory closure, not module-level:
|
|
942
|
+
// rebind instances (in-process task subagents) re-run the factory and each
|
|
943
|
+
// gets its own flag â the owner gate below is what actually excludes them.
|
|
944
|
+
let autoApplied = false;
|
|
945
|
+
|
|
946
|
+
// ââ W4 difficulty routing state (change: difficulty-routed-selection) ââ
|
|
947
|
+
// Factory closure, lazily keyed by session id (design D4: NOT hung on any
|
|
948
|
+
// session_start handler â that region belongs to the usage/autoselect
|
|
949
|
+
// owners). `band/confidence` come from classifyTaskSafe on the
|
|
950
|
+
// before_agent_start path; `stall` is the read-only sampling window fed
|
|
951
|
+
// by the tool_execution_start handler below.
|
|
952
|
+
let routingState: {
|
|
953
|
+
sessionId: string;
|
|
954
|
+
band: TaskBand;
|
|
955
|
+
confidence: number;
|
|
956
|
+
stallActive: boolean;
|
|
957
|
+
stall: StallRuntime | null;
|
|
958
|
+
} | null = null;
|
|
959
|
+
|
|
960
|
+
const routingStateFor = (sessionId: string) => {
|
|
961
|
+
if (!routingState || routingState.sessionId !== sessionId) {
|
|
962
|
+
routingState = {
|
|
963
|
+
sessionId,
|
|
964
|
+
band: 'standard',
|
|
965
|
+
confidence: 0,
|
|
966
|
+
stallActive: false,
|
|
967
|
+
stall: opsxConfig.stallEscalation.enabled ? createStallRuntime(opsxConfig.stallEscalation) : null,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return routingState;
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Routing runtime handed to computeSelectionSync (design D8.5). All
|
|
975
|
+
* switches off + balanced â `undefined` (the zero-drift contract: the
|
|
976
|
+
* selection path never even sees a routing object). `explicit` marks the
|
|
977
|
+
* /pick-model command paths â guard bypassed, no fresh classification,
|
|
978
|
+
* band annotations suppressed; the session band still drives offsets.
|
|
979
|
+
*/
|
|
980
|
+
const routingRuntimeFor = (ctx: ExtensionContext, explicit: boolean): RoutingRuntime | undefined => {
|
|
981
|
+
const qp = opsxConfig.qualityPreference;
|
|
982
|
+
const anyOn = opsxConfig.difficultyRouting.enabled
|
|
983
|
+
|| opsxConfig.stallEscalation.enabled
|
|
984
|
+
|| opsxConfig.continuityGuard.enabled
|
|
985
|
+
|| qp !== 'balanced';
|
|
986
|
+
if (!anyOn) return undefined;
|
|
987
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
988
|
+
return {
|
|
989
|
+
band: st.band,
|
|
990
|
+
confidence: st.confidence,
|
|
991
|
+
stallActive: st.stallActive,
|
|
992
|
+
explicit,
|
|
993
|
+
qualityPreference: qp,
|
|
994
|
+
// Incumbent read BEFORE the recompute reassigns `selection` â the
|
|
995
|
+
// pin decision compares against the previous context only.
|
|
996
|
+
guard: opsxConfig.continuityGuard.enabled && !explicit
|
|
997
|
+
? { weight: opsxConfig.continuityGuard.weight, incumbent: selection, priorBand: selection?.routingBand ?? null }
|
|
998
|
+
: undefined,
|
|
999
|
+
};
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// Same-account regional variant state (china-config): resolved fresh at
|
|
1003
|
+
// every recompute path from the live pool composition â no cache and
|
|
1004
|
+
// therefore no invalidation problem. The result feeds both
|
|
1005
|
+
// computeSelectionSync and the health key so the two cannot drift.
|
|
1006
|
+
// Judgment = variant-table membership + both sides in the effective pool
|
|
1007
|
+
// + strictly equal API keys (same-account proof; unequal/missing keys â
|
|
1008
|
+
// both doors coexist).
|
|
1009
|
+
const resolveVariantState = async (
|
|
1010
|
+
models: { provider: string; id: string }[] | null | undefined,
|
|
1011
|
+
getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
|
|
1012
|
+
reach: { unreachable?: Set<string>; availableByProvider?: Map<string, string[] | null> } = {},
|
|
1013
|
+
): Promise<VariantCollapseResult> => {
|
|
1014
|
+
const idle: VariantCollapseResult = { collapsedProviders: new Set<string>(), dropped: null, kept: null };
|
|
1015
|
+
if (!models || models.length === 0) return idle;
|
|
1016
|
+
// Trigger condition evaluates on the EFFECTIVE pool (spec: ååäŊåæļ
|
|
1017
|
+
// å¨æą æč§Ļåââreachability / éææé¤ / provider_models æļįĒ /
|
|
1018
|
+
// allowlist 厞æģ¤æä¸äž§æļä¸č§Ļå): the same provider-level filters
|
|
1019
|
+
// computeSelectionSync applies to basePool (minus the collapse
|
|
1020
|
+
// itself), plus the allowlist reduction. provider_models participates:
|
|
1021
|
+
// a kept side narrowed to zero must not count as "in pool", or the
|
|
1022
|
+
// collapse would drop the other side and leave the account unservable.
|
|
1023
|
+
const providerModelMap = Object.keys(opsxConfig.providerModels).length > 0
|
|
1024
|
+
? new Map(Object.entries(opsxConfig.providerModels))
|
|
1025
|
+
: undefined;
|
|
1026
|
+
const providersInPool = new Set(
|
|
1027
|
+
filterSelectableModels(models, {
|
|
1028
|
+
blockedSelectors: regionBlockedModels,
|
|
1029
|
+
unreachableProviders: reach.unreachable,
|
|
1030
|
+
availableByProvider: reach.availableByProvider,
|
|
1031
|
+
excludedProviders: opsxConfig.excluded_providers,
|
|
1032
|
+
providerModels: providerModelMap,
|
|
1033
|
+
})
|
|
1034
|
+
.filter((m) => providerSurvivesAllowlist(m.provider, m.id, opsxConfig.modelAllowlist))
|
|
1035
|
+
.map((m) => m.provider),
|
|
1036
|
+
);
|
|
1037
|
+
return resolveVariantCollapse({
|
|
1038
|
+
china: opsxConfig.china,
|
|
1039
|
+
providersInPool,
|
|
1040
|
+
getApiKey: await resolveVariantKeyLookup(providersInPool, getApiKey),
|
|
1041
|
+
});
|
|
1042
|
+
};
|
|
588
1043
|
|
|
589
1044
|
const buildSelectionHealthKey = (
|
|
590
1045
|
health: Map<string, ProviderHealth> | null,
|
|
591
1046
|
reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
|
|
592
1047
|
fc: FamilyConstraint | null,
|
|
1048
|
+
collapsed: ReadonlySet<string> = new Set<string>(),
|
|
593
1049
|
): string => {
|
|
594
|
-
return selectionHealthKeyFor(health, reach, fc, regionBlockedModels);
|
|
1050
|
+
return selectionHealthKeyFor(health, reach, fc, regionBlockedModels, collapsed);
|
|
595
1051
|
};
|
|
596
1052
|
|
|
597
1053
|
const formatFamilyConstraint = (fc: FamilyConstraint | null): string => {
|
|
598
|
-
if (!fc) return '';
|
|
599
1054
|
const parts: string[] = [];
|
|
600
|
-
if (fc
|
|
601
|
-
|
|
1055
|
+
if (fc?.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
|
|
1056
|
+
// china=on when the session constraint carries the exclusion OR the
|
|
1057
|
+
// config default is on â the config-derived segment renders even
|
|
1058
|
+
// without any selector constraint (spec: é
įŊŽæ´žį china=on æ įēĻæäšæžį¤ē).
|
|
1059
|
+
if ((fc?.excludeSelectors.length ?? 0) > 0 || effectiveConfig.china) parts.push('china=on');
|
|
1060
|
+
if (fc?.roles && fc.roles.length > 0) parts.push(`scope=${fc.roles.join(',')}`);
|
|
602
1061
|
return parts.join(' ¡ ');
|
|
603
1062
|
};
|
|
604
1063
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1064
|
+
/**
|
|
1065
|
+
* Copy of opsxConfig with the constrained roles forced to auto â covers
|
|
1066
|
+
* pinned roles. `scopedSlots === null` = global constraint (every role
|
|
1067
|
+
* auto, status-quo shape); scoped constraints only force the roles whose
|
|
1068
|
+
* consumed tier slot is in scope (coder feeds mid, the rest high) â
|
|
1069
|
+
* out-of-scope roles keep their opsx.yml value, so synthPinnedResult
|
|
1070
|
+
* keeps their pin and auto roles draw the unconstrained pool. The no-arg
|
|
1071
|
+
* poller/assembly call sites keep the all-auto semantics.
|
|
1072
|
+
*/
|
|
1073
|
+
const buildEffectiveConfig = (scopedSlots: ReadonlySet<TierName> | null = null): ResolvedOpsxConfig => {
|
|
1074
|
+
const agents = {} as Record<Role, AgentValue>;
|
|
1075
|
+
for (const role of OPSX_AGENTS) {
|
|
1076
|
+
const slot: TierName = role === 'coder' ? 'mid' : 'high';
|
|
1077
|
+
agents[role] = scopedSlots === null || scopedSlots.has(slot) ? { kind: 'auto' } : opsxConfig.agents[role];
|
|
1078
|
+
}
|
|
1079
|
+
return { ...opsxConfig, agents };
|
|
610
1080
|
};
|
|
611
1081
|
const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
|
|
612
1082
|
const auth = ctx.modelRegistry?.authStorage;
|
|
@@ -630,7 +1100,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
630
1100
|
});
|
|
631
1101
|
};
|
|
632
1102
|
|
|
633
|
-
const ensureSelection = (
|
|
1103
|
+
const ensureSelection = (
|
|
1104
|
+
ctx: ExtensionContext,
|
|
1105
|
+
force = false,
|
|
1106
|
+
explicit = false,
|
|
1107
|
+
opts?: { reprobeUnreachable?: boolean },
|
|
1108
|
+
): Promise<SelectionContext> => {
|
|
634
1109
|
if (selectionPromise && !force) return selectionPromise;
|
|
635
1110
|
selectionPromise = (async () => {
|
|
636
1111
|
await startPollerFor(ctx);
|
|
@@ -640,11 +1115,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
640
1115
|
} catch (e) {
|
|
641
1116
|
warn(`[omp-opsx-addon] modelRegistry error: ${e}`);
|
|
642
1117
|
}
|
|
643
|
-
const reach = await refreshReachability(models, opsxConfig);
|
|
1118
|
+
const reach = await refreshReachability(models, opsxConfig, opts);
|
|
644
1119
|
firstUnreachableProviders = reach.unreachable;
|
|
645
1120
|
availableByProvider = reach.availableByProvider;
|
|
646
1121
|
const health = getSharedUsage()?.health ?? null;
|
|
647
|
-
const
|
|
1122
|
+
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
1123
|
+
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
1124
|
+
: undefined;
|
|
1125
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1126
|
+
const key = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
648
1127
|
if (!force && selection && selectionHealthKey === key) return selection;
|
|
649
1128
|
// Thread effectiveConfig + familyConstraint so family/pinned constraints
|
|
650
1129
|
// survive this recompute (ensureSelection is one of the shared recompute paths).
|
|
@@ -652,11 +1131,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
652
1131
|
effectiveConfig,
|
|
653
1132
|
models,
|
|
654
1133
|
health,
|
|
655
|
-
currentKnownRoleIds(),
|
|
656
1134
|
regionBlockedModels,
|
|
657
1135
|
reach.unreachable,
|
|
658
1136
|
reach.availableByProvider,
|
|
659
1137
|
familyConstraint,
|
|
1138
|
+
collapsed,
|
|
1139
|
+
warn,
|
|
1140
|
+
routingRuntimeFor(ctx, explicit),
|
|
660
1141
|
);
|
|
661
1142
|
selection = sel;
|
|
662
1143
|
selectionHealthKey = key;
|
|
@@ -688,6 +1169,35 @@ export default (pi: ExtensionAPI) => {
|
|
|
688
1169
|
// would be overwritten by whichever instance starts last.
|
|
689
1170
|
let recordsOwnUsage = false;
|
|
690
1171
|
|
|
1172
|
+
// ââ adaptive provider concurrency: host seams âââââââââââââââââ
|
|
1173
|
+
/** Host settings port for the control loop's hot-apply (narrowed to `override`). */
|
|
1174
|
+
const hostSettingsPort = (): SettingsPort | null => {
|
|
1175
|
+
const settings = pi.pi?.settings as unknown as { override?: unknown } | undefined;
|
|
1176
|
+
return settings && typeof settings.override === 'function' ? (settings as SettingsPort) : null;
|
|
1177
|
+
};
|
|
1178
|
+
/** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
|
|
1179
|
+
const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
|
|
1180
|
+
let agentDir = '';
|
|
1181
|
+
try {
|
|
1182
|
+
agentDir = (pi.pi?.settings as { getAgentDir?: () => string } | undefined)?.getAgentDir?.() ?? '';
|
|
1183
|
+
} catch {
|
|
1184
|
+
agentDir = '';
|
|
1185
|
+
}
|
|
1186
|
+
if (!agentDir) agentDir = join(homedir(), '.omp', 'agent');
|
|
1187
|
+
return { agentDir, configPath: resolveGlobalConfigPath(agentDir) ?? join(agentDir, 'config.yml') };
|
|
1188
|
+
};
|
|
1189
|
+
/** The existing Redis channel as the â aggregation transport (no new command surface). */
|
|
1190
|
+
const aggregationRedisPort = (cfg: ResolvedOpsxConfig): ConcurrencyRedisPort | null => {
|
|
1191
|
+
if (!cfg.redis_enabled) return null;
|
|
1192
|
+
const client = new RedisClient({
|
|
1193
|
+
host: cfg.redis_host,
|
|
1194
|
+
port: cfg.redis_port,
|
|
1195
|
+
connectTimeoutMs: cfg.redis_connect_timeout_ms,
|
|
1196
|
+
commandTimeoutMs: cfg.redis_command_timeout_ms,
|
|
1197
|
+
});
|
|
1198
|
+
return { pipeline: (commands) => client.pipeline(commands) };
|
|
1199
|
+
};
|
|
1200
|
+
|
|
691
1201
|
// ââ shared usage poller + widget ââââââââââââââââââââââââââââââ
|
|
692
1202
|
pi.on('session_start', async (_event, ctx) => {
|
|
693
1203
|
try {
|
|
@@ -821,8 +1331,23 @@ export default (pi: ExtensionAPI) => {
|
|
|
821
1331
|
// Last unix second already sampled by onTick (per session). Drives
|
|
822
1332
|
// the catch-up loop that backfills every skipped second with zeros.
|
|
823
1333
|
let lastSampledSec: number | undefined;
|
|
824
|
-
const render = () => {
|
|
825
|
-
const
|
|
1334
|
+
const render = async () => {
|
|
1335
|
+
const resolved = resolveActiveProviders();
|
|
1336
|
+
// Same-account regional collapse on the usage bar (china-config):
|
|
1337
|
+
// a collapsed side is a duplicate door into the quota the kept
|
|
1338
|
+
// side already displays, so dropping its row loses nothing.
|
|
1339
|
+
// In-pool for usage = usage data or credentials present (the
|
|
1340
|
+
// `active` union) â model candidates are not required. The
|
|
1341
|
+
// strict key-equality gate matches the pool path: unequal or
|
|
1342
|
+
// missing keys keep BOTH rows (labels æēč°ą / Z.AI distinguish).
|
|
1343
|
+
const activeSet = new Set(resolved.active);
|
|
1344
|
+
const collapsed = resolveVariantCollapse({
|
|
1345
|
+
china: opsxConfig.china,
|
|
1346
|
+
providersInPool: activeSet,
|
|
1347
|
+
getApiKey: await resolveVariantKeyLookup(activeSet, getApiKey),
|
|
1348
|
+
}).collapsedProviders;
|
|
1349
|
+
const { reports, placeholderIds, active } = collapseUsageView(resolved.reports, resolved.placeholderIds, resolved.active, collapsed);
|
|
1350
|
+
const show = resolved.show;
|
|
826
1351
|
const state = getSharedUsage();
|
|
827
1352
|
const loadingProviders = state?.loadingProviders ?? new Set<string>();
|
|
828
1353
|
if (!show) {
|
|
@@ -852,14 +1377,28 @@ export default (pi: ExtensionAPI) => {
|
|
|
852
1377
|
for (const p of active) {
|
|
853
1378
|
consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
|
|
854
1379
|
}
|
|
1380
|
+
// Adaptive-concurrency inline tokens: the controller's per-provider
|
|
1381
|
+
// view (`stateFor`) rendered as `L/ceiling` right after the provider
|
|
1382
|
+
// name on BOTH render paths. Providers the controller does not manage
|
|
1383
|
+
// stay absent from the map â and an empty map keeps the widget
|
|
1384
|
+
// byte-identical to the pre-token output.
|
|
1385
|
+
const concurrencyViews = new Map<string, ConcurrencyInlineView>();
|
|
1386
|
+
if (ownedConcurrency) {
|
|
1387
|
+
const degraded = ownedConcurrency.controller.leaseMode() === 'readonly';
|
|
1388
|
+
for (const p of active) {
|
|
1389
|
+
const view = ownedConcurrency.controller.stateFor(p);
|
|
1390
|
+
if (view) concurrencyViews.set(p, { limit: view.limit, ceiling: view.ceiling, degraded });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const inlineConcurrency = concurrencyViews.size > 0 ? concurrencyViews : undefined;
|
|
855
1394
|
if (ctx.hasUI) {
|
|
856
|
-
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
|
|
1395
|
+
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency), {
|
|
857
1396
|
placement: 'belowEditor',
|
|
858
1397
|
});
|
|
859
1398
|
} else {
|
|
860
1399
|
if (ctx.ui) {
|
|
861
1400
|
const cols = (process.stdout.columns ?? 120) - 3;
|
|
862
|
-
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
|
|
1401
|
+
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency);
|
|
863
1402
|
ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
|
|
864
1403
|
}
|
|
865
1404
|
}
|
|
@@ -870,8 +1409,41 @@ export default (pi: ExtensionAPI) => {
|
|
|
870
1409
|
// subagent via cache backflow), and re-renders the widget. It never
|
|
871
1410
|
// recomputes a selection, never applies overrides, never setModel.
|
|
872
1411
|
const cfg = buildEffectiveConfig();
|
|
1412
|
+
// ââ claim the process-wide adaptive-concurrency control loop ââ
|
|
1413
|
+
// Only the owning session starts it (same rule as the render tick
|
|
1414
|
+
// above), and the module-level `ownedConcurrency` gate keeps a
|
|
1415
|
+
// subagent binding â or a re-entrant session_start â from starting a
|
|
1416
|
+
// second loop and lease against the same config root. The
|
|
1417
|
+
// `concurrency_enabled` check also short-circuits here so the
|
|
1418
|
+
// disabled path allocates nothing (no transport, no config read).
|
|
1419
|
+
if (claimsOwnership && ownedConcurrency === null && cfg.concurrency_enabled) {
|
|
1420
|
+
const paths = resolveConcurrencyPaths();
|
|
1421
|
+
const controller = createConcurrencyControllerIfEnabled(cfg, {
|
|
1422
|
+
configPath: paths.configPath,
|
|
1423
|
+
agentDir: paths.agentDir,
|
|
1424
|
+
settings: hostSettingsPort(),
|
|
1425
|
+
redis: concurrencyRedisPortForTests !== undefined ? concurrencyRedisPortForTests : aggregationRedisPort(cfg),
|
|
1426
|
+
warn,
|
|
1427
|
+
});
|
|
1428
|
+
if (controller) {
|
|
1429
|
+
ownedConcurrency = {
|
|
1430
|
+
sessionId: ctx.sessionManager?.getSessionId?.() ?? '',
|
|
1431
|
+
controller,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
873
1435
|
const onTick = () => {
|
|
874
|
-
//
|
|
1436
|
+
// Adaptive concurrency rides the SAME render tick â it owns no
|
|
1437
|
+
// timer; the controller's absolute-time gate (`concurrency_eval_
|
|
1438
|
+
// interval_ms`) decides whether this tick evaluates.
|
|
1439
|
+
const controller = ownedConcurrency?.controller;
|
|
1440
|
+
if (controller) {
|
|
1441
|
+
void controller.onTick().catch((e) => warn(`[omp-opsx-addon] concurrency tick error: ${e}`));
|
|
1442
|
+
}
|
|
1443
|
+
// Fire-and-forget status probe: do not block the tick. Deliberately
|
|
1444
|
+
// NOT filtered by the regional collapse (china-config): probing the
|
|
1445
|
+
// collapsed side keeps its reachability record fresh, so the
|
|
1446
|
+
// selection-pool premise of the collapse never rides stale data.
|
|
875
1447
|
const { placeholderIds, active } = resolveActiveProviders();
|
|
876
1448
|
let models: { id: string; provider: string; baseUrl?: string }[] = [];
|
|
877
1449
|
try {
|
|
@@ -897,7 +1469,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
897
1469
|
timeoutMs: cfg.probe_timeout_ms,
|
|
898
1470
|
excluded: cfg.excluded_providers,
|
|
899
1471
|
},
|
|
900
|
-
}).then((written) => { if (written > 0) render(); }).catch(() => {});
|
|
1472
|
+
}).then((written) => { if (written > 0) void render().catch(() => {}); }).catch(() => {});
|
|
901
1473
|
|
|
902
1474
|
sessionUsageRecorder.flush();
|
|
903
1475
|
// Waveform source = per-second consumption sequence (timestamped
|
|
@@ -923,7 +1495,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
923
1495
|
}
|
|
924
1496
|
}
|
|
925
1497
|
lastSampledSec = lastSec;
|
|
926
|
-
render();
|
|
1498
|
+
void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
|
|
927
1499
|
};
|
|
928
1500
|
await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
|
|
929
1501
|
onTick: claimsOwnership ? onTick : undefined,
|
|
@@ -941,14 +1513,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
941
1513
|
},
|
|
942
1514
|
});
|
|
943
1515
|
// One resize listener per process: each new owning session replaces
|
|
944
|
-
// the previous handler instead of
|
|
1516
|
+
// the previous handler in the module-level slot instead of
|
|
1517
|
+
// accumulating across factory re-bindings. (ExtensionContext
|
|
945
1518
|
// exposes no `signal`, so per-session abort cleanup never fires â
|
|
946
1519
|
// ownership above, not teardown hooks, keeps the singletons sane.)
|
|
947
1520
|
if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
|
|
948
|
-
const onResize = () => render();
|
|
1521
|
+
const onResize = () => void render().catch(() => {});
|
|
949
1522
|
activeResizeHandler = onResize;
|
|
950
1523
|
process.stdout.on('resize', onResize);
|
|
951
|
-
render();
|
|
1524
|
+
void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
|
|
952
1525
|
} catch (e) {
|
|
953
1526
|
warn(`[omp-opsx-addon] usage poller error: ${e}`);
|
|
954
1527
|
}
|
|
@@ -1046,25 +1619,76 @@ export default (pi: ExtensionAPI) => {
|
|
|
1046
1619
|
}
|
|
1047
1620
|
}
|
|
1048
1621
|
}
|
|
1622
|
+
// The control loop belongs to ONE session: only the owner's own exit
|
|
1623
|
+
// stops it. A subagent's shutdown MUST NOT tear down the root's loop
|
|
1624
|
+
// and release its lease (identity gate, see `ownedConcurrency`).
|
|
1625
|
+
if (ownedConcurrency && sid === ownedConcurrency.sessionId) {
|
|
1626
|
+
const owned = ownedConcurrency;
|
|
1627
|
+
ownedConcurrency = null;
|
|
1628
|
+
try {
|
|
1629
|
+
await owned.controller.shutdown();
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
warn(`[omp-opsx-addon] concurrency shutdown error: ${e}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1049
1634
|
} catch {
|
|
1050
1635
|
/* best-effort */
|
|
1051
1636
|
}
|
|
1052
1637
|
});
|
|
1053
1638
|
// ââ session lifecycle âââââââââââââââââââââââââââââââââââââââââ
|
|
1054
|
-
// Pick-model
|
|
1639
|
+
// Pick-model stays manual (`/pick-model refresh`); with autoselect_on_start
|
|
1640
|
+
// (default true) the session_start handler below additionally applies the
|
|
1641
|
+
// configured selection once at startup, so it already holds for turn one.
|
|
1055
1642
|
const changeScanner = createCachedScanner(30_000);
|
|
1056
1643
|
|
|
1644
|
+
// ââ W4 routing segment (change: difficulty-routed-selection) ââ
|
|
1645
|
+
// The gate wraps ONLY this segment: the handler's existing body below
|
|
1646
|
+
// (systemPrompt injection + change scanning) runs unchanged under every
|
|
1647
|
+
// switch combination â gating never retires the whole handler (P1-1).
|
|
1648
|
+
// Both gates mirror the existing instance rules: the feature switches
|
|
1649
|
+
// (both off â straight out, zero behavior) and the main-instance rule
|
|
1650
|
+
// (subagent bindings never classify or sample). The band/stall comparison
|
|
1651
|
+
// decides whether one force recompute happens this turn; otherwise the
|
|
1652
|
+
// existing health-key caching applies untouched.
|
|
1653
|
+
const runRoutingSegment = async (event: { prompt: string }, ctx: ExtensionContext): Promise<void> => {
|
|
1654
|
+
if (!opsxConfig.difficultyRouting.enabled && !opsxConfig.stallEscalation.enabled) return;
|
|
1655
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1656
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
1657
|
+
const classification = opsxConfig.difficultyRouting.enabled
|
|
1658
|
+
? classifyTaskSafe(event.prompt)
|
|
1659
|
+
: { band: 'standard' as TaskBand, confidence: 0 };
|
|
1660
|
+
// Stall episode evaluation for this turn (design D4: anchored on the
|
|
1661
|
+
// newest signature, escalated latches once per episode). The window
|
|
1662
|
+
// content is maintained by the tool_execution_start handler below.
|
|
1663
|
+
let stallActive = false;
|
|
1664
|
+
if (opsxConfig.stallEscalation.enabled && st.stall) {
|
|
1665
|
+
stallActive = evaluateStall(st.stall).active;
|
|
1666
|
+
}
|
|
1667
|
+
const prevBand = st.band;
|
|
1668
|
+
const prevStallActive = st.stallActive;
|
|
1669
|
+
st.band = classification.band;
|
|
1670
|
+
st.confidence = classification.confidence;
|
|
1671
|
+
st.stallActive = stallActive;
|
|
1672
|
+
const bandMoved = opsxConfig.difficultyRouting.enabled && prevBand !== classification.band;
|
|
1673
|
+
const stallMoved = opsxConfig.stallEscalation.enabled && prevStallActive !== stallActive;
|
|
1674
|
+
if (bandMoved || stallMoved) await ensureSelection(ctx, true);
|
|
1675
|
+
};
|
|
1676
|
+
|
|
1057
1677
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
1058
1678
|
try {
|
|
1679
|
+
// Best-effort isolation (review P2): the segment's force-recompute
|
|
1680
|
+
// awaits poller/Redis and cache-fs paths absent from this handler
|
|
1681
|
+
// pre-W4; a rejection must never reach the outer catch and strip
|
|
1682
|
+
// STATIC_PROMPT + the change-scan segment for the turn.
|
|
1683
|
+
try {
|
|
1684
|
+
await runRoutingSegment(event, ctx);
|
|
1685
|
+
} catch (segErr) {
|
|
1686
|
+
warn(`[omp-opsx-addon] routing segment error: ${segErr}`);
|
|
1687
|
+
}
|
|
1059
1688
|
// This fallback call is intentionally NOT threaded with familyConstraint:
|
|
1060
1689
|
// it prefers the cached `selection`, and the fallback passes models=null so
|
|
1061
1690
|
// filterSelectableModels' family branch never runs â it is not a reselect path.
|
|
1062
|
-
const sel = selection ?? computeSelectionSync(
|
|
1063
|
-
opsxConfig,
|
|
1064
|
-
null,
|
|
1065
|
-
null,
|
|
1066
|
-
safeGetKnownRoleIds(pi.pi?.settings),
|
|
1067
|
-
);
|
|
1691
|
+
const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
|
|
1068
1692
|
const changes = await changeScanner.scanChanges(directory, 'doing');
|
|
1069
1693
|
// Layered injection: STATIC_PROMPT is byte-identical every turn â prefix cache
|
|
1070
1694
|
// hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
|
|
@@ -1079,7 +1703,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1079
1703
|
pi.on('session_start', async (_event, ctx) => {
|
|
1080
1704
|
try {
|
|
1081
1705
|
const frontmatter = (role: Role): string | undefined =>
|
|
1082
|
-
frontmatterModel(role, opsxConfig.
|
|
1706
|
+
frontmatterModel(role, opsxConfig.agents[role]);
|
|
1083
1707
|
await installAgents(
|
|
1084
1708
|
directory,
|
|
1085
1709
|
frontmatter('coder'),
|
|
@@ -1087,11 +1711,83 @@ export default (pi: ExtensionAPI) => {
|
|
|
1087
1711
|
frontmatter('planner'),
|
|
1088
1712
|
frontmatter('proposal-reviewer'),
|
|
1089
1713
|
);
|
|
1090
|
-
warn('[omp-opsx-addon] agents ready (
|
|
1714
|
+
warn('[omp-opsx-addon] agents ready (selection auto-applies at session start unless autoselect_on_start: false; /pick-model to re-run)');
|
|
1091
1715
|
} catch (e) {
|
|
1092
1716
|
warn('[omp-opsx-addon] installAgents error: ' + e);
|
|
1093
1717
|
}
|
|
1094
1718
|
});
|
|
1719
|
+
|
|
1720
|
+
// Session-start autoselect: apply the configured selection once so the
|
|
1721
|
+
// first turn already follows it â role overrides through the same apply
|
|
1722
|
+
// path as /pick-model, and the main session moved to the reviewer pick
|
|
1723
|
+
// (no-op when already equal; undefined pick â skip). The once-flag is also
|
|
1724
|
+
// what keeps a later in-session /model switch authoritative: this path
|
|
1725
|
+
// never runs again for the instance, so nothing rewrites the user's
|
|
1726
|
+
// choice. Owner gate before the once-flag: subagent bindings re-run the
|
|
1727
|
+
// factory with independent closures (a closure once-flag cannot see
|
|
1728
|
+
// them), so the process-level owner rule decides â same rule as the
|
|
1729
|
+
// usage/render claim in the first handler. First attempt is awaited
|
|
1730
|
+
// (first-turn-before-prompt contract); cold-start no-pick recovery is a
|
|
1731
|
+
// bounded fire-and-forget retry chain that does not block the handler.
|
|
1732
|
+
// Pins (opsx.yml agents.*) bypass selection via synthPinnedResult and are
|
|
1733
|
+
// never touched here.
|
|
1734
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1735
|
+
if (!opsxConfig.autoselectOnStart) return;
|
|
1736
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1737
|
+
if (autoApplied) return;
|
|
1738
|
+
autoApplied = true;
|
|
1739
|
+
|
|
1740
|
+
const modelKeyOf = (m: { provider?: string; id?: string } | null | undefined): string | null =>
|
|
1741
|
+
m?.provider && m?.id ? `${m.provider}/${m.id}` : null;
|
|
1742
|
+
const entryModelKey = modelKeyOf(ctx.model);
|
|
1743
|
+
|
|
1744
|
+
const attemptOnce = async (reprobeUnreachable: boolean): Promise<'picked' | 'no-pick' | 'error'> => {
|
|
1745
|
+
try {
|
|
1746
|
+
const sel = await ensureSelection(
|
|
1747
|
+
ctx,
|
|
1748
|
+
false,
|
|
1749
|
+
false,
|
|
1750
|
+
reprobeUnreachable ? { reprobeUnreachable: true } : undefined,
|
|
1751
|
+
);
|
|
1752
|
+
applyRoleModel(sel);
|
|
1753
|
+
const picked = sel.selectorResults.reviewer?.picked;
|
|
1754
|
+
if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
|
|
1755
|
+
await pi.setModel(picked);
|
|
1756
|
+
}
|
|
1757
|
+
return picked ? 'picked' : 'no-pick';
|
|
1758
|
+
} catch (e) {
|
|
1759
|
+
warn(`[omp-opsx-addon] session-start autoselect error: ${e}`);
|
|
1760
|
+
return 'error';
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
const first = await attemptOnce(false);
|
|
1765
|
+
if (first !== 'no-pick') return;
|
|
1766
|
+
|
|
1767
|
+
// Fire-and-forget bounded recovery: first attempt already failed with
|
|
1768
|
+
// no reviewer pick (typical cold-start probe/registry lag). Do not
|
|
1769
|
+
// await this chain in the handler â that would delay the first turn.
|
|
1770
|
+
void (async () => {
|
|
1771
|
+
try {
|
|
1772
|
+
for (const delay of AUTOSELECT_RETRY_DELAYS_MS) {
|
|
1773
|
+
await new Promise<void>((r) => setTimeout(r, delay));
|
|
1774
|
+
// Yield to an explicit user /model or manual /pick-model
|
|
1775
|
+
// (both change the live session model via setModel).
|
|
1776
|
+
const live = typeof ctx.models?.current === 'function' ? ctx.models.current() : undefined;
|
|
1777
|
+
if (live !== undefined && live !== null && modelKeyOf(live) !== entryModelKey) {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const outcome = await attemptOnce(true);
|
|
1781
|
+
if (outcome !== 'no-pick') return;
|
|
1782
|
+
}
|
|
1783
|
+
warn(
|
|
1784
|
+
'[omp-opsx-addon] session-start autoselect: retries exhausted; run /pick-model to apply manually',
|
|
1785
|
+
);
|
|
1786
|
+
} catch (e) {
|
|
1787
|
+
warn(`[omp-opsx-addon] session-start autoselect retry error: ${e}`);
|
|
1788
|
+
}
|
|
1789
|
+
})();
|
|
1790
|
+
});
|
|
1095
1791
|
// ââ Diagnostic-only error path (429/403; never auto-switches) â
|
|
1096
1792
|
const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
|
|
1097
1793
|
const currentModel = ctx.model;
|
|
@@ -1105,6 +1801,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
1105
1801
|
// without a usage fetcher â record it in the process-local binary status
|
|
1106
1802
|
// so the placeholder column reads čå°Ŋ (see lib/provider-status.ts).
|
|
1107
1803
|
markExhausted(canonical);
|
|
1804
|
+
// Adaptive-concurrency signal â : hand the observed explicit failure to
|
|
1805
|
+
// the control loop as a COUNTED event only. The diagnosis text and the
|
|
1806
|
+
// "never auto-switch provider" policy below are unchanged (task 6.5).
|
|
1807
|
+
ownedConcurrency?.controller.noteExplicitFailure(canonical, errorType);
|
|
1108
1808
|
// Force-refresh just this provider (bypassing the 10s floor) so the
|
|
1109
1809
|
// diagnosis reflects current quota.
|
|
1110
1810
|
if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
|
|
@@ -1148,6 +1848,44 @@ export default (pi: ExtensionAPI) => {
|
|
|
1148
1848
|
warn(`[omp-opsx-addon] tool_execution_end error: ${e}`);
|
|
1149
1849
|
}
|
|
1150
1850
|
});
|
|
1851
|
+
// ââ W4 stall sampling (change: difficulty-routed-selection; read-only) ââ
|
|
1852
|
+
// Freshly registered handlers (unlike before_agent_start, which predates
|
|
1853
|
+
// W4): the first-line switch gate IS the whole disabled behavior â zero
|
|
1854
|
+
// sampling, zero state writes. Main-instance gate mirrors autoselect:
|
|
1855
|
+
// subagent bindings never sample (design D4). The window only shapes the
|
|
1856
|
+
// NEXT selection recompute's inputs; nothing here switches a live model
|
|
1857
|
+
// and the 429/403 diagnostics above are untouched (T4).
|
|
1858
|
+
pi.on('tool_execution_start', async (event, ctx) => {
|
|
1859
|
+
if (!opsxConfig.stallEscalation.enabled) return;
|
|
1860
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1861
|
+
try {
|
|
1862
|
+
const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
1863
|
+
const st = routingStateFor(sessionId);
|
|
1864
|
+
if (!st.stall) return;
|
|
1865
|
+
recordToolCall(st.stall, sessionId, toolSignature(event.toolName, event.args));
|
|
1866
|
+
} catch {
|
|
1867
|
+
/* stall sampling is best-effort; stay silent */
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
pi.on('turn_end', async (_event, ctx) => {
|
|
1871
|
+
if (!opsxConfig.stallEscalation.enabled) return;
|
|
1872
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1873
|
+
try {
|
|
1874
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
1875
|
+
if (!st.stall) return;
|
|
1876
|
+
// Transcription fallback (design D4): the event stream is the
|
|
1877
|
+
// primary source; when it delivered nothing for this session
|
|
1878
|
+
// (process reload), rebuild the near window from the read-only
|
|
1879
|
+
// transcript. Empty/corrupt entries idle the feature â never a
|
|
1880
|
+
// downgrade, no warn noise into the selection path.
|
|
1881
|
+
if (st.stall.signatures.length === 0) {
|
|
1882
|
+
rebuildFromEntries(st.stall, ctx.sessionManager.getEntries());
|
|
1883
|
+
}
|
|
1884
|
+
evaluateStall(st.stall);
|
|
1885
|
+
} catch {
|
|
1886
|
+
/* stall evaluation is best-effort; stay silent */
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1151
1889
|
pi.on('input', async (event) => {
|
|
1152
1890
|
const match = event.text.match(/^@(coder|reviewer|planner|proposal-reviewer|code-reviewer)\b(.*)$/i);
|
|
1153
1891
|
if (!match) return;
|
|
@@ -1165,12 +1903,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
1165
1903
|
|
|
1166
1904
|
pi.setLabel('pick-model', 'æ¨ĄåéæŠ');
|
|
1167
1905
|
pi.registerCommand('pick-model', {
|
|
1168
|
-
description: 'æ¨ĄåįŽĄįīŧchoices åąį¤ē / refresh éé / update æ´æ°æĄŖäŊ / <selector> [--china]
|
|
1906
|
+
description: 'æ¨ĄåįŽĄįīŧchoices åąį¤ē / refresh éé / update æ´æ°æĄŖäŊ / [roleList:]<selector> [--china] æ selector įēĻæīŧå¯ role åŽåīŧåĻ smol:glm*īŧč¯čĄ¨ smol/default/âĻ+agent åĢå/allīŧčŖ¸č¯Âˇglob¡provider/id åæŽĩīŧ/ reset æ¸
é¤įēĻæ',
|
|
1169
1907
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1170
1908
|
try {
|
|
1909
|
+
const symbols = resolveSymbols(pi.pi?.settings);
|
|
1171
1910
|
const parsed = parseAutoModelArgs(args);
|
|
1172
1911
|
if (parsed.kind === 'error') {
|
|
1173
|
-
pi.sendMessage({ customType: 'pick-model', content:
|
|
1912
|
+
pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${parsed.message}`, display: true }, { triggerTurn: false });
|
|
1174
1913
|
return;
|
|
1175
1914
|
}
|
|
1176
1915
|
|
|
@@ -1178,8 +1917,8 @@ export default (pi: ExtensionAPI) => {
|
|
|
1178
1917
|
if (parsed.kind === 'update') {
|
|
1179
1918
|
const result = updateTiers();
|
|
1180
1919
|
const msg = result.ok
|
|
1181
|
-
?
|
|
1182
|
-
:
|
|
1920
|
+
? `${symbols.success} Tiers data updated: ${result.count} entries`
|
|
1921
|
+
: `${symbols.error} Update failed: ${result.error}`;
|
|
1183
1922
|
pi.sendMessage({ customType: 'pick-model', content: msg, display: true }, { triggerTurn: false });
|
|
1184
1923
|
return;
|
|
1185
1924
|
}
|
|
@@ -1196,15 +1935,20 @@ export default (pi: ExtensionAPI) => {
|
|
|
1196
1935
|
firstUnreachableProviders = reach.unreachable;
|
|
1197
1936
|
availableByProvider = reach.availableByProvider;
|
|
1198
1937
|
const health = getSharedUsage()?.health ?? null;
|
|
1938
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1199
1939
|
const freshSel = computeSelectionSync(
|
|
1200
1940
|
effectiveConfig,
|
|
1201
1941
|
models,
|
|
1202
1942
|
health,
|
|
1203
|
-
currentKnownRoleIds(),
|
|
1204
1943
|
regionBlockedModels,
|
|
1205
1944
|
reach.unreachable,
|
|
1206
1945
|
reach.availableByProvider,
|
|
1207
1946
|
familyConstraint,
|
|
1947
|
+
collapsed,
|
|
1948
|
+
warn,
|
|
1949
|
+
// /pick-model refresh: explicit path (design D5) â
|
|
1950
|
+
// guard bypassed, no fresh classification.
|
|
1951
|
+
routingRuntimeFor(ctx, true),
|
|
1208
1952
|
);
|
|
1209
1953
|
const changes: string[] = [];
|
|
1210
1954
|
// Check primary session model
|
|
@@ -1215,33 +1959,47 @@ export default (pi: ExtensionAPI) => {
|
|
|
1215
1959
|
const primaryBest = primaryResult?.candidates[0];
|
|
1216
1960
|
if (primaryBest && `${currentModel.provider}/${currentModel.id}` !== `${primaryBest.model.provider}/${primaryBest.model.id}`) {
|
|
1217
1961
|
await pi.setModel(primaryBest.model);
|
|
1218
|
-
changes.push(
|
|
1962
|
+
changes.push(formatChangeLine({
|
|
1963
|
+
name: 'ä¸ģäŧč¯',
|
|
1964
|
+
to: `${primaryBest.model.provider}/${primaryBest.model.id}`,
|
|
1965
|
+
coverage: coverageLabel(primaryBest.coverageClass),
|
|
1966
|
+
tier: tierLabel(primaryBest.tier),
|
|
1967
|
+
}));
|
|
1219
1968
|
}
|
|
1220
1969
|
}
|
|
1221
1970
|
// Check all agent models
|
|
1222
|
-
for (const role of
|
|
1971
|
+
for (const role of OPSX_AGENTS) {
|
|
1223
1972
|
const result = freshSel.selectorResults[role];
|
|
1224
1973
|
const picked = result?.picked;
|
|
1225
1974
|
if (!picked) continue;
|
|
1226
1975
|
const agent = AGENT_MAP[role];
|
|
1227
1976
|
const pickedSel = `${picked.provider}/${picked.id}`;
|
|
1228
|
-
const roleCfg = opsxConfig.
|
|
1977
|
+
const roleCfg = opsxConfig.agents[role];
|
|
1229
1978
|
if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
|
|
1230
1979
|
const pinned = roleValueToModelString(roleCfg);
|
|
1231
1980
|
if (pinned && pinned !== pickedSel) {
|
|
1232
|
-
changes.push(
|
|
1981
|
+
changes.push(formatChangeLine({
|
|
1982
|
+
name: `${agent}īŧ厞æåŽ ${pinned}īŧ`,
|
|
1983
|
+
to: pickedSel,
|
|
1984
|
+
...annotationOfPicked(result, picked),
|
|
1985
|
+
kind: roleKindLabel(freshSel.roleKind[role]),
|
|
1986
|
+
}));
|
|
1233
1987
|
}
|
|
1234
1988
|
} else {
|
|
1235
|
-
changes.push(
|
|
1989
|
+
changes.push(formatChangeLine({
|
|
1990
|
+
name: agent,
|
|
1991
|
+
to: pickedSel,
|
|
1992
|
+
...annotationOfPicked(result, picked),
|
|
1993
|
+
kind: roleKindLabel(freshSel.roleKind[role]),
|
|
1994
|
+
}));
|
|
1236
1995
|
}
|
|
1237
1996
|
}
|
|
1238
1997
|
selection = freshSel;
|
|
1239
1998
|
applyRoleModel(freshSel);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1999
|
+
const content = changes.length > 0
|
|
2000
|
+
? renderPickModelReport('refresh', { action: '厞县°', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
|
|
2001
|
+
: renderPickModelReport('refresh', { action: '厞县° ¡ æææ¨ĄåįļææŖå¸¸īŧæ éåæĸ' }, symbols);
|
|
2002
|
+
pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
|
|
1245
2003
|
return;
|
|
1246
2004
|
}
|
|
1247
2005
|
|
|
@@ -1259,15 +2017,20 @@ export default (pi: ExtensionAPI) => {
|
|
|
1259
2017
|
firstUnreachableProviders = reach.unreachable;
|
|
1260
2018
|
availableByProvider = reach.availableByProvider;
|
|
1261
2019
|
const health = getSharedUsage()?.health ?? null;
|
|
2020
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1262
2021
|
const sel = computeSelectionSync(
|
|
1263
2022
|
effectiveConfig,
|
|
1264
2023
|
models,
|
|
1265
2024
|
health,
|
|
1266
|
-
currentKnownRoleIds(),
|
|
1267
2025
|
regionBlockedModels,
|
|
1268
2026
|
reach.unreachable,
|
|
1269
2027
|
reach.availableByProvider,
|
|
1270
2028
|
familyConstraint,
|
|
2029
|
+
collapsed,
|
|
2030
|
+
warn,
|
|
2031
|
+
// /pick-model reset: explicit path (design D5) â
|
|
2032
|
+
// guard bypassed, no fresh classification.
|
|
2033
|
+
routingRuntimeFor(ctx, true),
|
|
1271
2034
|
);
|
|
1272
2035
|
const changes: string[] = [];
|
|
1273
2036
|
const currentModel = ctx.model;
|
|
@@ -1275,25 +2038,37 @@ export default (pi: ExtensionAPI) => {
|
|
|
1275
2038
|
if (newPrimary) {
|
|
1276
2039
|
const newSel = `${newPrimary.provider}/${newPrimary.id}`;
|
|
1277
2040
|
const oldSel = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
|
|
1278
|
-
changes.push(
|
|
2041
|
+
changes.push(formatChangeLine({
|
|
2042
|
+
name: 'ä¸ģäŧč¯',
|
|
2043
|
+
from: oldSel && oldSel !== newSel ? oldSel : undefined,
|
|
2044
|
+
to: newSel,
|
|
2045
|
+
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
2046
|
+
}));
|
|
1279
2047
|
}
|
|
1280
|
-
for (const role of
|
|
2048
|
+
for (const role of OPSX_AGENTS) {
|
|
1281
2049
|
const picked = sel.selectorResults[role]?.picked;
|
|
1282
2050
|
if (!picked) continue;
|
|
1283
2051
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
1284
2052
|
const prev = selection?.selectorResults[role]?.picked;
|
|
1285
2053
|
const oldSel = prev ? `${prev.provider}/${prev.id}` : undefined;
|
|
1286
|
-
changes.push(
|
|
2054
|
+
changes.push(formatChangeLine({
|
|
2055
|
+
name: AGENT_MAP[role],
|
|
2056
|
+
from: oldSel ?? '(none)',
|
|
2057
|
+
to: newSel,
|
|
2058
|
+
...annotationOfPicked(sel.selectorResults[role], picked),
|
|
2059
|
+
kind: roleKindLabel(sel.roleKind[role]),
|
|
2060
|
+
}));
|
|
1287
2061
|
}
|
|
1288
2062
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1289
2063
|
selection = sel;
|
|
1290
|
-
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
2064
|
+
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
1291
2065
|
applyRoleModel(sel);
|
|
1292
2066
|
pi.sendMessage({
|
|
1293
2067
|
customType: 'pick-model',
|
|
1294
|
-
content:
|
|
1295
|
-
?
|
|
1296
|
-
:
|
|
2068
|
+
content: renderPickModelReport('reset', {
|
|
2069
|
+
action: changes.length > 0 ? '厞æ¸
é¤ selector įēĻæīŧæĸå¤éģčŽ¤éæŠ' : '厞æ¸
é¤ selector įēĻæīŧæĸå¤éģčŽ¤éæŠīŧæ ååīŧ',
|
|
2070
|
+
detail: changes.length > 0 ? [changes.join('\n')] : undefined,
|
|
2071
|
+
}, symbols),
|
|
1297
2072
|
display: true,
|
|
1298
2073
|
}, { triggerTurn: false });
|
|
1299
2074
|
return;
|
|
@@ -1312,6 +2087,24 @@ export default (pi: ExtensionAPI) => {
|
|
|
1312
2087
|
availableByProvider = reach.availableByProvider;
|
|
1313
2088
|
const health = getSharedUsage()?.health ?? null;
|
|
1314
2089
|
const excludeSelectors = parsed.china ? CHINA_EXCLUDE_FAMILIES : [];
|
|
2090
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
2091
|
+
|
|
2092
|
+
// Fail fast on an inexpressible scope (model_role_tiers skip
|
|
2093
|
+
// sentinel) BEFORE touching any session state: a skip role
|
|
2094
|
+
// sits on no tier slot, so a pool constraint on it cannot be
|
|
2095
|
+
// expressed â never silently narrow the scope or no-op.
|
|
2096
|
+
let scopedSlots: ReadonlySet<TierName> | null = null;
|
|
2097
|
+
if (parsed.roleScope) {
|
|
2098
|
+
try {
|
|
2099
|
+
scopedSlots = scopedTierSlots(parsed.roleScope, opsxConfig.ompRoleTiers);
|
|
2100
|
+
} catch (e) {
|
|
2101
|
+
if (e instanceof ScopedRoleSkipError) {
|
|
2102
|
+
pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${e.message}`, display: true }, { triggerTurn: false });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
throw e;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
1315
2108
|
|
|
1316
2109
|
// Switch the session-scoped state BEFORE any recompute so every
|
|
1317
2110
|
// later recompute path (ensureSelection, 429/403 auto-switch,
|
|
@@ -1320,25 +2113,35 @@ export default (pi: ExtensionAPI) => {
|
|
|
1320
2113
|
// command restores it rather than silently dropping an active one.
|
|
1321
2114
|
const prevConstraint = familyConstraint;
|
|
1322
2115
|
const prevEffective = effectiveConfig;
|
|
1323
|
-
familyConstraint = { selectors: parsed.selectors, excludeSelectors };
|
|
1324
|
-
effectiveConfig = buildEffectiveConfig();
|
|
2116
|
+
familyConstraint = { selectors: parsed.selectors, excludeSelectors, roles: parsed.roleScope ?? null };
|
|
2117
|
+
effectiveConfig = buildEffectiveConfig(scopedSlots);
|
|
1325
2118
|
|
|
1326
2119
|
// Fail fast: an empty candidate set (or a role that cannot pick)
|
|
1327
2120
|
// is a hard error â never silently pick nothing or change models.
|
|
2121
|
+
// Mirror computeSelectionSync's pool shape exactly (variant
|
|
2122
|
+
// collapse on the provider axis + command exclude else standing
|
|
2123
|
+
// china default) so the precheck cannot pass on a pool the real
|
|
2124
|
+
// recompute would keep, or vice versa.
|
|
2125
|
+
const standingExclude = opsxConfig.china ? CHINA_EXCLUDE_FAMILIES : undefined;
|
|
1328
2126
|
const preFiltered = filterSelectableModels(models, {
|
|
1329
2127
|
blockedSelectors: regionBlockedModels,
|
|
1330
2128
|
unreachableProviders: reach.unreachable,
|
|
1331
|
-
excludedProviders: effectiveConfig.excluded_providers,
|
|
2129
|
+
excludedProviders: [...effectiveConfig.excluded_providers, ...collapsed],
|
|
1332
2130
|
availableByProvider: reach.availableByProvider,
|
|
1333
2131
|
selectors: parsed.selectors ?? undefined,
|
|
1334
|
-
excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors :
|
|
2132
|
+
excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : standingExclude,
|
|
2133
|
+
providerModels: Object.keys(effectiveConfig.providerModels).length > 0
|
|
2134
|
+
? new Map(Object.entries(effectiveConfig.providerModels))
|
|
2135
|
+
: undefined,
|
|
1335
2136
|
});
|
|
1336
2137
|
if (preFiltered.length === 0) {
|
|
1337
2138
|
familyConstraint = prevConstraint;
|
|
1338
2139
|
effectiveConfig = prevEffective;
|
|
1339
2140
|
pi.sendMessage({
|
|
1340
2141
|
customType: 'pick-model',
|
|
1341
|
-
content:
|
|
2142
|
+
content: renderPickModelReport('error', {
|
|
2143
|
+
action: `selector įēϿ䏿 å¯į¨æ¨Ąåã\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
|
|
2144
|
+
}, symbols),
|
|
1342
2145
|
display: true,
|
|
1343
2146
|
}, { triggerTurn: false });
|
|
1344
2147
|
return;
|
|
@@ -1348,19 +2151,32 @@ export default (pi: ExtensionAPI) => {
|
|
|
1348
2151
|
effectiveConfig,
|
|
1349
2152
|
models,
|
|
1350
2153
|
health,
|
|
1351
|
-
currentKnownRoleIds(),
|
|
1352
2154
|
regionBlockedModels,
|
|
1353
2155
|
reach.unreachable,
|
|
1354
2156
|
reach.availableByProvider,
|
|
1355
2157
|
familyConstraint,
|
|
2158
|
+
collapsed,
|
|
2159
|
+
warn,
|
|
2160
|
+
// /pick-model <selector>: explicit path (design D5) â
|
|
2161
|
+
// guard bypassed, no fresh classification.
|
|
2162
|
+
routingRuntimeFor(ctx, true),
|
|
2163
|
+
);
|
|
2164
|
+
// pick-model-role-scope: only roles this command actually put
|
|
2165
|
+
// into the pool (roleKind 'auto' in the effective config) can
|
|
2166
|
+
// fail to pick. Out-of-scope pinned/omp-alias roles keep their
|
|
2167
|
+
// config value and synthPinnedResult never carries a picked â
|
|
2168
|
+
// counting them would roll back every scoped constraint.
|
|
2169
|
+
const emptyRole = OPSX_AGENTS.find(
|
|
2170
|
+
(r) => sel.roleKind[r] === 'auto' && !sel.selectorResults[r]?.picked,
|
|
1356
2171
|
);
|
|
1357
|
-
const emptyRole = OPSX_ROLES.find((r) => !sel.selectorResults[r]?.picked);
|
|
1358
2172
|
if (emptyRole) {
|
|
1359
2173
|
familyConstraint = prevConstraint;
|
|
1360
2174
|
effectiveConfig = prevEffective;
|
|
1361
2175
|
pi.sendMessage({
|
|
1362
2176
|
customType: 'pick-model',
|
|
1363
|
-
content:
|
|
2177
|
+
content: renderPickModelReport('error', {
|
|
2178
|
+
action: `selector įēϿ䏿 æŗä¸ē role "${emptyRole}" éæŠæ¨Ąåã\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
|
|
2179
|
+
}, symbols),
|
|
1364
2180
|
display: true,
|
|
1365
2181
|
}, { triggerTurn: false });
|
|
1366
2182
|
return;
|
|
@@ -1369,7 +2185,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1369
2185
|
const currentModel = ctx.model;
|
|
1370
2186
|
const oldPrimary = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
|
|
1371
2187
|
const oldRolePicks: Record<string, string | undefined> = {};
|
|
1372
|
-
for (const role of
|
|
2188
|
+
for (const role of OPSX_AGENTS) {
|
|
1373
2189
|
const prev = selection?.selectorResults[role]?.picked;
|
|
1374
2190
|
oldRolePicks[role] = prev ? `${prev.provider}/${prev.id}` : undefined;
|
|
1375
2191
|
}
|
|
@@ -1378,14 +2194,25 @@ export default (pi: ExtensionAPI) => {
|
|
|
1378
2194
|
const changes: string[] = [];
|
|
1379
2195
|
if (newPrimary) {
|
|
1380
2196
|
const newSel = `${newPrimary.provider}/${newPrimary.id}`;
|
|
1381
|
-
changes.push(
|
|
2197
|
+
changes.push(formatChangeLine({
|
|
2198
|
+
name: 'ä¸ģäŧč¯',
|
|
2199
|
+
from: oldPrimary && oldPrimary !== newSel ? oldPrimary : undefined,
|
|
2200
|
+
to: newSel,
|
|
2201
|
+
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
2202
|
+
}));
|
|
1382
2203
|
}
|
|
1383
|
-
for (const role of
|
|
2204
|
+
for (const role of OPSX_AGENTS) {
|
|
1384
2205
|
const picked = sel.selectorResults[role]?.picked;
|
|
1385
2206
|
if (!picked) continue;
|
|
1386
2207
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
1387
2208
|
const oldSel = oldRolePicks[role];
|
|
1388
|
-
changes.push(
|
|
2209
|
+
changes.push(formatChangeLine({
|
|
2210
|
+
name: AGENT_MAP[role],
|
|
2211
|
+
from: oldSel ?? '(none)',
|
|
2212
|
+
to: newSel,
|
|
2213
|
+
...annotationOfPicked(sel.selectorResults[role], picked),
|
|
2214
|
+
kind: roleKindLabel(sel.roleKind[role]),
|
|
2215
|
+
}));
|
|
1389
2216
|
}
|
|
1390
2217
|
|
|
1391
2218
|
// Cross-provider ambiguity hint (design Decision 8): only when a
|
|
@@ -1407,9 +2234,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
1407
2234
|
|
|
1408
2235
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1409
2236
|
selection = sel;
|
|
1410
|
-
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
2237
|
+
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
1411
2238
|
applyRoleModel(sel);
|
|
1412
|
-
const content =
|
|
2239
|
+
const content = renderPickModelReport('switch', {
|
|
2240
|
+
action: '厞åæĸ selector įēĻæ',
|
|
2241
|
+
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
2242
|
+
detail: changes.length > 0 ? [changes.join('\n')] : undefined,
|
|
2243
|
+
footnote: hint || undefined,
|
|
2244
|
+
}, symbols);
|
|
1413
2245
|
pi.sendMessage({
|
|
1414
2246
|
customType: 'pick-model',
|
|
1415
2247
|
content,
|
|
@@ -1420,22 +2252,59 @@ export default (pi: ExtensionAPI) => {
|
|
|
1420
2252
|
|
|
1421
2253
|
// ââ /pick-model choices (default) ââââââââââââââââ
|
|
1422
2254
|
const sel = await getSelectionForCommand(ctx);
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
2255
|
+
const roleLines = formatRoleLines(
|
|
2256
|
+
OPSX_AGENTS.map((role) => ({
|
|
2257
|
+
name: AGENT_MAP[role],
|
|
2258
|
+
kind: sel.roleKind[role],
|
|
2259
|
+
fallbackModel: sel.roleModels[role],
|
|
2260
|
+
result: sel.selectorResults[role],
|
|
2261
|
+
})),
|
|
2262
|
+
ctx.model
|
|
2263
|
+
? {
|
|
2264
|
+
model: { provider: ctx.model.provider, id: ctx.model.id },
|
|
2265
|
+
tier: scoreModelWithProvider(ctx.model.provider, ctx.model.id, ctx.model.name, undefined).tier,
|
|
2266
|
+
}
|
|
2267
|
+
: undefined,
|
|
2268
|
+
);
|
|
2269
|
+
// Full agent inventory (project + user + bundled, host-merged).
|
|
2270
|
+
// Failure downgrades to omitting the section + warn; never blocks.
|
|
2271
|
+
let agentInventory: string | undefined;
|
|
2272
|
+
try {
|
|
2273
|
+
agentInventory = formatAgentInventory((await discoverAgents(directory)).agents, symbols);
|
|
2274
|
+
} catch (err) {
|
|
2275
|
+
warn(`[omp-opsx-addon] agent inventory unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
2276
|
+
}
|
|
2277
|
+
const detail: string[] = [];
|
|
2278
|
+
if (roleLines.length > 0) detail.push(roleLines.join('\n'));
|
|
2279
|
+
if (agentInventory) detail.push(agentInventory);
|
|
2280
|
+
const concurrencySummary = ownedConcurrency?.controller.summaryLines() ?? [];
|
|
2281
|
+
// Regional-variant appendix line (china-config): resolved fresh from
|
|
2282
|
+
// the live registry with the same resolution the recompute paths use.
|
|
2283
|
+
const poolModels = ctx.modelRegistry?.getAvailable() ?? [];
|
|
2284
|
+
const choicesGetApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
2285
|
+
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
2286
|
+
: undefined;
|
|
2287
|
+
const variantLine = variantCollapseAnnotation(
|
|
2288
|
+
await resolveVariantState(poolModels, choicesGetApiKey, {
|
|
2289
|
+
unreachable: firstUnreachableProviders,
|
|
2290
|
+
availableByProvider,
|
|
2291
|
+
}),
|
|
2292
|
+
);
|
|
2293
|
+
const appendixParts: string[] = [];
|
|
2294
|
+
if (concurrencySummary.length > 0) appendixParts.push('**åšļåæ§åļ**', ...concurrencySummary);
|
|
2295
|
+
if (variantLine) appendixParts.push(variantLine);
|
|
2296
|
+
const content = renderPickModelReport('choices', {
|
|
2297
|
+
action: 'åŊåéæŠ',
|
|
2298
|
+
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
2299
|
+
detail,
|
|
2300
|
+
overview: formatCandidateOverview(OPSX_AGENTS.map((role) => sel.selectorResults[role])),
|
|
2301
|
+
appendix: appendixParts.length > 0 ? appendixParts.join('\n') : undefined,
|
|
2302
|
+
footnote: formatFootnote(OPSX_AGENTS.map((role) => sel.selectorResults[role]), sel.paygLastResort),
|
|
2303
|
+
}, symbols);
|
|
2304
|
+
pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
|
|
1436
2305
|
} catch (e) {
|
|
1437
2306
|
warn(`[omp-opsx-addon] /pick-model error: ${e}`);
|
|
1438
|
-
pi.sendMessage({ customType: 'pick-model', content:
|
|
2307
|
+
pi.sendMessage({ customType: 'pick-model', content: `${resolveSymbols(pi.pi?.settings).error} Error: ${e}`, display: true }, { triggerTurn: false });
|
|
1439
2308
|
}
|
|
1440
2309
|
},
|
|
1441
2310
|
});
|
|
@@ -1461,7 +2330,9 @@ export default (pi: ExtensionAPI) => {
|
|
|
1461
2330
|
|
|
1462
2331
|
async function getSelectionForCommand(ctx: ExtensionCommandContext): Promise<SelectionContext> {
|
|
1463
2332
|
if (selection) return selection;
|
|
1464
|
-
|
|
2333
|
+
// Explicit command path: guard bypassed, no fresh classification
|
|
2334
|
+
// (design D5 â the lazy compute exists only for a cache miss).
|
|
2335
|
+
return ensureSelection(ctx, false, true);
|
|
1465
2336
|
}
|
|
1466
2337
|
|
|
1467
2338
|
// Cross-broker project-scoped pipe core: registers opsx_pipe_list /
|