@genee/omp-opsx-addon 0.7.0 → 0.9.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 +441 -71
- package/index.ts +1030 -208
- package/lib/agent-defs.ts +55 -82
- 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/direct-fetchers.ts +18 -3
- package/lib/family-filter.ts +115 -2
- package/lib/model-roles.ts +41 -39
- 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 +325 -98
- package/lib/pipe-push.ts +136 -5
- package/lib/provider-variants.ts +154 -0
- package/lib/selection-filters.ts +42 -1
- package/lib/stall-detector.ts +185 -0
- package/lib/system-prompt.ts +17 -9
- package/lib/tiers-data.ts +40 -5
- package/lib/tiers-updater.ts +51 -8
- package/lib/unified-config.ts +732 -27
- package/lib/usage-poller.ts +44 -1
- 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 RoleValue, OPSX_ROLES } from './lib/unified-config.js';
|
|
117
|
+
import { readOpsxSettings, resolveManagedProviders, type ResolvedOpsxConfig, type RoleValue, OPSX_ROLES } 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). */
|
|
@@ -261,12 +383,12 @@ export interface SelectionContext {
|
|
|
261
383
|
roleOverrides: Record<string, string>;
|
|
262
384
|
/** Known roles left out of the overlay payload (config skip / capability gate / no pick). */
|
|
263
385
|
skippedRoles: SkippedRole[];
|
|
386
|
+
/** payg_last_resort the selection was computed with — drives the 分区 fold annotation in choices markdown. Default true. */
|
|
387
|
+
paygLastResort?: boolean;
|
|
388
|
+
/** W4 routing: the task band this selection was computed with (undefined = routing runtime absent). */
|
|
389
|
+
routingBand?: TaskBand;
|
|
264
390
|
}
|
|
265
391
|
|
|
266
|
-
const DEFAULT_PLAN_PROVIDERS = new Set(['minimax-code-cn', 'opencode-go', 'zhipu-coding-plan', 'kimi', 'openai-codex']);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
392
|
function roleValueToModelString(value: RoleValue): string | undefined {
|
|
271
393
|
if (value.kind === 'auto') return undefined;
|
|
272
394
|
if (value.kind === 'omp-alias') return `@${value.alias}`;
|
|
@@ -305,53 +427,126 @@ export function buildDispatchPrompt(role: Role, task: string, selection: Selecti
|
|
|
305
427
|
else parts.push(`请直接通过 task 工具调用 ${agent}(无需指定 model,模型按 role 自动解析)。`);
|
|
306
428
|
if (sel?.decision) parts.push(`原因: ${sel.decision}`);
|
|
307
429
|
parts.push('直接用内置工具实现。');
|
|
430
|
+
if (role === 'coder') parts.push(`${SCOPE_FENCE_TAG}与本任务无关的异常状态(别人未提交的改动、无关失败测试、无关脏文件、工作树状态与预期不符):最小相关性核查(如 git status、确认被改动文件是否落在本次改动面内)不受限;判定为无关后 MUST NOT 调查改动来源、MUST NOT 暂停当前任务,改为记录假设、写入交付摘要 SUMMARY、继续执行当前任务;真阻塞(缺依赖 / 契约动摇 / 权限不足 / 需用户决策)仍立即 STATUS: blocked。`);
|
|
308
431
|
return `请使用 task 工具委派给 ${agent}:\ntask(agent:"${agent}"${modelHint ? `, model:"${modelHint}"` : ''}, task:"${parts.join(' ')}\\n\\n${task}")`;
|
|
309
432
|
}
|
|
310
433
|
|
|
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
434
|
function computeSelectionSync(
|
|
328
435
|
config: ResolvedOpsxConfig,
|
|
329
436
|
models: Model<Api>[] | null,
|
|
330
437
|
health: Map<string, ProviderHealth> | null,
|
|
331
|
-
knownRoleIds: string[],
|
|
332
438
|
regionBlockedModels?: Set<string>,
|
|
333
439
|
unreachableProviders?: Set<string>,
|
|
334
440
|
availableByProvider?: Map<string, string[] | null>,
|
|
335
441
|
familyConstraint?: FamilyConstraint | null,
|
|
442
|
+
collapsedProviders?: ReadonlySet<string>,
|
|
443
|
+
onWarn?: (msg: string) => void,
|
|
444
|
+
/** W4 routing runtime (design D8.5); undefined = all switches off + balanced → byte-identical legacy path. */
|
|
445
|
+
routing?: RoutingRuntime,
|
|
336
446
|
): SelectionContext {
|
|
337
|
-
const
|
|
447
|
+
const fc = familyConstraint ?? null;
|
|
448
|
+
// pick-model-role-scope: pool split. basePool = reachability/provider
|
|
449
|
+
// filtering plus account-level regional-variant dedup (china-config: the
|
|
450
|
+
// collapse set is resolved at the call sites and narrows EVERY slot,
|
|
451
|
+
// scoped or not — account-level, orthogonal to constraints);
|
|
452
|
+
// filteredModels = basePool further narrowed by the selector filter and
|
|
453
|
+
// the effective china exclusion (explicit `--china` constraint first, else
|
|
454
|
+
// the `china: true` config default). Global constraints feed every slot
|
|
455
|
+
// from the single filtered pool (status-quo shape); scoped constraints
|
|
456
|
+
// only the slots in scope — everything else draws basePool, which
|
|
457
|
+
// deliberately KEEPS china families (role-scope contract: out-of-scope
|
|
458
|
+
// slots never inherit the standing exclusion).
|
|
459
|
+
const scopeRoles = fc?.roles && fc.roles.length > 0 ? fc.roles : null;
|
|
460
|
+
const scopedSlots = scopedTierSlots(scopeRoles, config.ompRoleTiers);
|
|
461
|
+
// provider-auto-targets: config-static per-provider narrowing at the pool
|
|
462
|
+
// layer — every downstream slot (tierPicks / vision / role pools) inherits it.
|
|
463
|
+
const providerModelMap = Object.keys(config.providerModels).length > 0
|
|
464
|
+
? new Map(Object.entries(config.providerModels))
|
|
465
|
+
: undefined;
|
|
466
|
+
const providerModelWipes = new Set<string>();
|
|
467
|
+
const basePool = models
|
|
338
468
|
? filterSelectableModels(models, {
|
|
339
469
|
blockedSelectors: regionBlockedModels,
|
|
340
470
|
unreachableProviders,
|
|
341
|
-
excludedProviders: config.excluded_providers,
|
|
471
|
+
excludedProviders: [...config.excluded_providers, ...(collapsedProviders ?? [])],
|
|
342
472
|
availableByProvider,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
familyConstraint?.excludeSelectors && familyConstraint.excludeSelectors.length > 0
|
|
346
|
-
? familyConstraint.excludeSelectors
|
|
347
|
-
: undefined,
|
|
473
|
+
providerModels: providerModelMap,
|
|
474
|
+
zeroHitProviders: providerModelWipes,
|
|
348
475
|
})
|
|
349
476
|
: models;
|
|
477
|
+
if (onWarn) {
|
|
478
|
+
for (const p of providerModelWipes) {
|
|
479
|
+
onWarn(`[omp-opsx-addon] provider_models.${p}: 收窄零命中(候选全部未命中模式,检查模式拼写是否与 catalog id 一致)`);
|
|
480
|
+
}
|
|
481
|
+
// Sentinel id drift (catalog renamed its auto entry): marking silently
|
|
482
|
+
// falls back to heuristic scoring — surface it instead of staying quiet.
|
|
483
|
+
for (const [p, id] of Object.entries(config.providerAuto)) {
|
|
484
|
+
const inPool = basePool?.some((m) => canonicalizeProvider(m.provider) === p);
|
|
485
|
+
if (!inPool) continue;
|
|
486
|
+
const hit = basePool?.some((m) => canonicalizeProvider(m.provider) === p && m.id.toLowerCase() === id.toLowerCase());
|
|
487
|
+
if (!hit) onWarn(`[omp-opsx-addon] provider_auto.${p}: 声明的哨兵 id "${id}" 不在 ${p} 的存活候选中(catalog id 已漂移?标记失效,回落普通评分)`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// 命令显式 > 配置缺省: a constraint carrying excludeSelectors wins; the
|
|
491
|
+
// config default only fills in when the constraint carries none. Invariant:
|
|
492
|
+
// excludeSelectors has exactly one write site (parsed.china →
|
|
493
|
+
// CHINA_EXCLUDE_FAMILIES in the selector branch), so non-empty ≡ explicit.
|
|
494
|
+
const effectiveExclude =
|
|
495
|
+
fc?.excludeSelectors && fc.excludeSelectors.length > 0
|
|
496
|
+
? fc.excludeSelectors
|
|
497
|
+
: config.china
|
|
498
|
+
? CHINA_EXCLUDE_FAMILIES
|
|
499
|
+
: undefined;
|
|
500
|
+
const filteredModels = basePool
|
|
501
|
+
? filterSelectableModels(basePool, {
|
|
502
|
+
selectors: fc?.selectors ?? undefined,
|
|
503
|
+
excludeSelectors: effectiveExclude,
|
|
504
|
+
})
|
|
505
|
+
: basePool;
|
|
506
|
+
// Pool a slot draws from: global constraints (roles null) constrain every
|
|
507
|
+
// slot; scoped constraints only the slots the scope roles map onto.
|
|
508
|
+
const poolForSlot = (slot: TierName): Model<Api>[] | null =>
|
|
509
|
+
scopeRoles === null || scopedSlots.has(slot) ? filteredModels : basePool;
|
|
350
510
|
const selectorResults = {} as Record<Role, SelectorResult>;
|
|
351
511
|
const roleModels = {} as Record<Role, string | undefined>;
|
|
352
512
|
const roleConfigs = {} as Record<Role, string | undefined>;
|
|
353
513
|
const roleKind = {} as Record<Role, RoleValue['kind']>;
|
|
354
|
-
const planSet = mergePlanProviders(config.planProviders);
|
|
514
|
+
const planSet = mergePlanProviders(config.planProviders, config.planProvidersRemove);
|
|
515
|
+
// Speed-aware selection: undefined when disabled — zero DB IO, and the
|
|
516
|
+
// selector keeps the frozen W1 order byte-for-byte.
|
|
517
|
+
const speedAware = resolveSpeedAware(config.speedAware);
|
|
518
|
+
// ── W4 difficulty routing (design D3/D5) ──────────────────────
|
|
519
|
+
// `routing === undefined` keeps every map/value at its legacy identity —
|
|
520
|
+
// the zero-drift structure is "not entering" the new paths, not an
|
|
521
|
+
// equivalent re-derivation. Explicit command paths KEEP the session-band
|
|
522
|
+
// offsets (design D5: 沿用当前会话级 band) and skip only guard pinning and
|
|
523
|
+
// the band annotation (显式指令 > 守门;显式路径不做新分类).
|
|
524
|
+
// Offsets are SELECTOR-TARGET pre-corrections only: the role→slot mapping
|
|
525
|
+
// (buildRoleOverridePlan's input) stays structurally untouched — smol
|
|
526
|
+
// always consumes the coder pick, slow the top supplemental, so shifted
|
|
527
|
+
// targets flow through the existing slots.
|
|
528
|
+
// Selector-side offsets key on the OMP write-set alias (smol/default/
|
|
529
|
+
// slow/vision); opsx roles translate via ROLE_TO_OMP_ALIAS.
|
|
530
|
+
const routedTierFor = (role: string, ompRole: string | undefined, base: TierName): TierName => {
|
|
531
|
+
if (!routing || !ompRole) return base;
|
|
532
|
+
// The per-role map carries only this role's base tier, so a `skip`
|
|
533
|
+
// sentinel can never appear here — but the offset table's value type
|
|
534
|
+
// admits it, and falling back to the base keeps that safe.
|
|
535
|
+
const routed = applyBandOffset({ [ompRole]: base }, routing.band, { stallActive: routing.stallActive });
|
|
536
|
+
const value = routed[ompRole];
|
|
537
|
+
return value === 'skip' || value === undefined ? base : value;
|
|
538
|
+
};
|
|
539
|
+
// Data-layer annotations (design D9): `band=<band>` when routing drives
|
|
540
|
+
// the recompute, `stall+1` on the roles aliased to default while stall is
|
|
541
|
+
// active. Explicit paths suppress the band segment (no fresh
|
|
542
|
+
// classification).
|
|
543
|
+
const noteFor = (role: string): string | undefined => {
|
|
544
|
+
if (!routing) return undefined;
|
|
545
|
+
const parts: string[] = [];
|
|
546
|
+
if (!routing.explicit && config.difficultyRouting.enabled) parts.push(`band=${routing.band}`);
|
|
547
|
+
if (routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default') parts.push('stall+1');
|
|
548
|
+
return parts.length > 0 ? parts.join(', ') : undefined;
|
|
549
|
+
};
|
|
355
550
|
for (const role of OPSX_ROLES) {
|
|
356
551
|
const value = config.roles[role];
|
|
357
552
|
roleKind[role] = value.kind;
|
|
@@ -362,20 +557,31 @@ function computeSelectionSync(
|
|
|
362
557
|
: (roleValueToModelString(value) ?? '?');
|
|
363
558
|
const alias = `@${ROLE_TO_OMP_ALIAS[role]}`;
|
|
364
559
|
if (value.kind === 'auto') {
|
|
365
|
-
|
|
560
|
+
// coder's pick feeds the mid slot, the other three feed high —
|
|
561
|
+
// the same consumed-slot mapping buildEffectiveConfig and the
|
|
562
|
+
// tierPicks assembly below use.
|
|
563
|
+
const pool = poolForSlot(role === 'coder' ? 'mid' : 'high');
|
|
564
|
+
if (pool && pool.length > 0) {
|
|
366
565
|
const ompRole = ROLE_TO_OMP_ALIAS[role];
|
|
367
|
-
const tier = (config.roleTiers[role]
|
|
566
|
+
const tier = routedTierFor(role, ompRole, (config.roleTiers[role]
|
|
368
567
|
?? (OMP_ROLE_TO_TIER as Record<string, TierName>)[ompRole]
|
|
369
|
-
?? 'mid') as TierName;
|
|
568
|
+
?? 'mid') as TierName);
|
|
370
569
|
const roleTiers = { ...config.roleTiers, [role]: tier };
|
|
371
570
|
const result = selectAllRoles(
|
|
372
571
|
[role],
|
|
373
|
-
|
|
572
|
+
pool,
|
|
374
573
|
roleTiers,
|
|
375
574
|
config.tierOverrides,
|
|
376
575
|
health ?? undefined,
|
|
377
576
|
config.modelAllowlist,
|
|
378
577
|
planSet,
|
|
578
|
+
undefined,
|
|
579
|
+
config.paygLastResort,
|
|
580
|
+
config.providerAuto,
|
|
581
|
+
config.providerAutoTiers,
|
|
582
|
+
speedAware,
|
|
583
|
+
routing?.qualityPreference,
|
|
584
|
+
noteFor(role),
|
|
379
585
|
)[role];
|
|
380
586
|
selectorResults[role] = result;
|
|
381
587
|
roleModels[role] = result.picked ? alias : undefined;
|
|
@@ -397,123 +603,118 @@ function computeSelectionSync(
|
|
|
397
603
|
roleModels[role] = modelString?.includes('/') ? modelString : undefined;
|
|
398
604
|
}
|
|
399
605
|
}
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
606
|
+
// ── W4 continuity guard (design D5) ───────────────────────────
|
|
607
|
+
// Pin viable incumbents on auto recomputes — the pin outranks every
|
|
608
|
+
// tie-break; a switch needs a strict band upgrade at sufficient
|
|
609
|
+
// confidence, a stall escalation, or an incumbent hard-filtered out of
|
|
610
|
+
// the pool. Runs BEFORE the overlay plan so pinned picks flow into
|
|
611
|
+
// roleOverrides; explicit paths bypass (显式指令 > 守门).
|
|
612
|
+
if (routing?.guard && !routing.explicit) {
|
|
613
|
+
const incumbentResults = routing.guard.incumbent?.selectorResults ?? null;
|
|
614
|
+
for (const role of OPSX_ROLES) {
|
|
615
|
+
const prev = incumbentResults?.[role];
|
|
616
|
+
if (!prev?.picked) continue;
|
|
617
|
+
const cur = selectorResults[role];
|
|
618
|
+
const picked = prev.picked;
|
|
619
|
+
// Viability = the incumbent's selector still sits in the fresh
|
|
620
|
+
// candidate pool (zero registry queries — pool membership only).
|
|
621
|
+
const viable = cur.candidates.some((c) => c.model.provider === picked.provider && c.model.id === picked.id);
|
|
622
|
+
// Stall escalates the `default` alias only (reviewer/planner/
|
|
623
|
+
// proposal-reviewer); coder maps to smol and stays sticky.
|
|
624
|
+
const outcome = decideGuard({
|
|
625
|
+
enabled: true,
|
|
626
|
+
weight: routing.guard.weight,
|
|
627
|
+
explicit: false,
|
|
628
|
+
incumbent: prev,
|
|
629
|
+
incumbentViable: viable,
|
|
630
|
+
newBand: routing.band,
|
|
631
|
+
incumbentBand: routing.guard.priorBand ?? null,
|
|
632
|
+
confidence: routing.confidence,
|
|
633
|
+
stallEscalated: routing.stallActive && ROLE_TO_OMP_ALIAS[role] === 'default',
|
|
634
|
+
});
|
|
635
|
+
if (outcome.decision === 'pin' && outcome.pinned) {
|
|
636
|
+
selectorResults[role] = pinPriorPick(outcome.pinned, routing.band);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// change: pick-model-ux — minimal write-set session overlay {smol, default,
|
|
641
|
+
// slow, vision}: mid/high reuse the opsx picks (coder/reviewer), top is the
|
|
642
|
+
// sole supplemental pick (slow), vision picks from image-capable candidates
|
|
643
|
+
// only. Roles outside the set resolve through OMP native chains. See
|
|
644
|
+
// lib/model-roles.ts design D5.
|
|
405
645
|
let roleOverrides: Record<string, string> = {};
|
|
406
646
|
let skippedRoles: SkippedRole[] = [];
|
|
407
|
-
if (
|
|
647
|
+
if (basePool && basePool.length > 0) {
|
|
408
648
|
const pickString = (r: SelectorResult): string | undefined =>
|
|
409
649
|
r.picked ? `${r.picked.provider}/${r.picked.id}` : undefined;
|
|
410
650
|
const tierPicks: Partial<Record<TierName, string | undefined>> = {
|
|
651
|
+
// Guard pinning ran above: a pinned pick replaces the role result
|
|
652
|
+
// and therefore feeds the overlay payload (T4: application stays
|
|
653
|
+
// at the existing apply points — this only refreshes the cache).
|
|
411
654
|
mid: pickString(selectorResults.coder),
|
|
412
655
|
high: pickString(selectorResults.reviewer),
|
|
413
656
|
};
|
|
414
|
-
|
|
415
|
-
|
|
657
|
+
const synthRole = '__tier_top';
|
|
658
|
+
const topPool = poolForSlot('top');
|
|
659
|
+
const topSlotTier: TierName = routing
|
|
660
|
+
? applyBandOffset({ slow: 'top' }, routing.band, { stallActive: routing.stallActive }).slow as TierName
|
|
661
|
+
: 'top';
|
|
662
|
+
if (topPool && topPool.length > 0) {
|
|
416
663
|
const result = selectModel({
|
|
417
664
|
role: synthRole,
|
|
418
|
-
models:
|
|
419
|
-
|
|
665
|
+
models: topPool,
|
|
666
|
+
// This slot feeds the write-set `slow` role: its target shifts
|
|
667
|
+
// with the band (top−1 under simple etc., design D3), while the
|
|
668
|
+
// role→slot mapping itself stays fixed.
|
|
669
|
+
roleTiers: { [synthRole]: topSlotTier },
|
|
420
670
|
tierOverrides: config.tierOverrides,
|
|
421
671
|
health: health ?? undefined,
|
|
422
672
|
allowlist: config.modelAllowlist,
|
|
423
673
|
planProviders: planSet,
|
|
674
|
+
paygLastResort: config.paygLastResort,
|
|
675
|
+
providerAuto: config.providerAuto,
|
|
676
|
+
providerAutoTiers: config.providerAutoTiers,
|
|
677
|
+
speedAware,
|
|
678
|
+
qualityPreference: routing?.qualityPreference,
|
|
424
679
|
});
|
|
425
|
-
tierPicks
|
|
680
|
+
tierPicks.top = pickString(result);
|
|
426
681
|
}
|
|
427
682
|
const capabilityPicks: Record<string, string | undefined> = {};
|
|
428
|
-
|
|
683
|
+
// vision scope gates its capability candidate set only — it never
|
|
684
|
+
// contributes a tier slot.
|
|
685
|
+
const visionSource = scopeRoles?.includes('vision') ? filteredModels : basePool;
|
|
686
|
+
const visionModels = (visionSource ?? []).filter((m) => ROLE_CAPABILITY.vision(m));
|
|
429
687
|
if (visionModels.length > 0) {
|
|
430
688
|
const visionResult = selectModel({
|
|
431
689
|
role: 'vision',
|
|
432
690
|
models: visionModels,
|
|
433
|
-
|
|
691
|
+
// Write-set member: band offset shifts the in-pool preference
|
|
692
|
+
// (capability filter already ran — no incapable-model risk).
|
|
693
|
+
roleTiers: routing
|
|
694
|
+
? applyBandOffset({ vision: 'high' } as Record<string, TierName>, routing.band, { stallActive: routing.stallActive }) as Record<string, TierName>
|
|
695
|
+
: { vision: 'high' },
|
|
434
696
|
tierOverrides: config.tierOverrides,
|
|
435
697
|
health: health ?? undefined,
|
|
436
698
|
allowlist: config.modelAllowlist,
|
|
437
699
|
planProviders: planSet,
|
|
700
|
+
paygLastResort: config.paygLastResort,
|
|
701
|
+
providerAuto: config.providerAuto,
|
|
702
|
+
providerAutoTiers: config.providerAutoTiers,
|
|
703
|
+
speedAware,
|
|
704
|
+
qualityPreference: routing?.qualityPreference,
|
|
438
705
|
});
|
|
439
706
|
capabilityPicks.vision = pickString(visionResult);
|
|
440
707
|
}
|
|
441
|
-
const plan = buildRoleOverridePlan(
|
|
442
|
-
{ knownRoleIds, ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks },
|
|
443
|
-
(m) => getLogger().warn(m),
|
|
444
|
-
);
|
|
708
|
+
const plan = buildRoleOverridePlan({ ompRoleTiers: config.ompRoleTiers, tierPicks, capabilityPicks });
|
|
445
709
|
roleOverrides = plan.overrides;
|
|
446
710
|
skippedRoles = plan.skipped;
|
|
447
711
|
}
|
|
448
|
-
return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles };
|
|
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');
|
|
712
|
+
return { selectorResults, roleModels, roleConfigs, roleKind, roleOverrides, skippedRoles, paygLastResort: config.paygLastResort, routingBand: routing?.band };
|
|
516
713
|
}
|
|
714
|
+
// change: pick-model-ux — choices/switch/refresh/reset output rendering lives
|
|
715
|
+
// in lib/pick-model-render.ts (shared compact block renderer); the former
|
|
716
|
+
// formatModelChoicesMarkdown / formatFamilyTierMarkdown markdown tables were
|
|
717
|
+
// migrated there.
|
|
517
718
|
|
|
518
719
|
// ── process-wide usage recorder singleton ──────────────────────────
|
|
519
720
|
// This IS a process-wide singleton: the harness rebinds the same evaluated
|
|
@@ -540,6 +741,161 @@ const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
|
|
|
540
741
|
onDelta: mirrorSessionDelta,
|
|
541
742
|
});
|
|
542
743
|
|
|
744
|
+
// ── adaptive provider concurrency (process-wide owner) ──────────────
|
|
745
|
+
// Ownership model (the same P0 invariant as plugins/omp-whip/index.ts): the
|
|
746
|
+
// host re-runs this factory once per in-process subagent binding, so an owner
|
|
747
|
+
// kept in the closure would be claimed by EVERY binding — each starting its
|
|
748
|
+
// own control loop, lease and state file against the same config root. The
|
|
749
|
+
// owner therefore lives at module scope (shared by every binding in this
|
|
750
|
+
// process, and re-imported fresh by isolated workers), and the session id
|
|
751
|
+
// captured at claim time is the identity gate for `session_shutdown`: without
|
|
752
|
+
// it, a subagent exiting would tear down the root's loop and release its lease.
|
|
753
|
+
interface OwnedConcurrency {
|
|
754
|
+
/** Session id captured when this process claimed the control loop. */
|
|
755
|
+
sessionId: string;
|
|
756
|
+
controller: ConcurrencyController;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
let ownedConcurrency: OwnedConcurrency | 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
|
|
|
545
901
|
export default (pi: ExtensionAPI) => {
|
|
@@ -558,11 +914,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
558
914
|
applyEditVariantPin(pi, { 'deepseek-v4-flash': 'replace' });
|
|
559
915
|
const directory = process.cwd();
|
|
560
916
|
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);
|
|
917
|
+
const opsxConfig = readOpsxSettings(directory, warn, opsxGlobalHomeForTests ?? homedir());
|
|
566
918
|
// Static orchestration rules — built once, reused every turn for cache stability.
|
|
567
919
|
const STATIC_PROMPT = buildStaticPrompt();
|
|
568
920
|
let initialModel: { provider: string; id: string } | null = null;
|
|
@@ -585,27 +937,144 @@ export default (pi: ExtensionAPI) => {
|
|
|
585
937
|
// and `/pick-model reset` restores it to opsxConfig.
|
|
586
938
|
let familyConstraint: FamilyConstraint | null = null;
|
|
587
939
|
let effectiveConfig: ResolvedOpsxConfig = opsxConfig;
|
|
940
|
+
// Session-start autoselect once-flag. Factory closure, not module-level:
|
|
941
|
+
// rebind instances (in-process task subagents) re-run the factory and each
|
|
942
|
+
// gets its own flag — the owner gate below is what actually excludes them.
|
|
943
|
+
let autoApplied = false;
|
|
944
|
+
|
|
945
|
+
// ── W4 difficulty routing state (change: difficulty-routed-selection) ──
|
|
946
|
+
// Factory closure, lazily keyed by session id (design D4: NOT hung on any
|
|
947
|
+
// session_start handler — that region belongs to the usage/autoselect
|
|
948
|
+
// owners). `band/confidence` come from classifyTaskSafe on the
|
|
949
|
+
// before_agent_start path; `stall` is the read-only sampling window fed
|
|
950
|
+
// by the tool_execution_start handler below.
|
|
951
|
+
let routingState: {
|
|
952
|
+
sessionId: string;
|
|
953
|
+
band: TaskBand;
|
|
954
|
+
confidence: number;
|
|
955
|
+
stallActive: boolean;
|
|
956
|
+
stall: StallRuntime | null;
|
|
957
|
+
} | null = null;
|
|
958
|
+
|
|
959
|
+
const routingStateFor = (sessionId: string) => {
|
|
960
|
+
if (!routingState || routingState.sessionId !== sessionId) {
|
|
961
|
+
routingState = {
|
|
962
|
+
sessionId,
|
|
963
|
+
band: 'standard',
|
|
964
|
+
confidence: 0,
|
|
965
|
+
stallActive: false,
|
|
966
|
+
stall: opsxConfig.stallEscalation.enabled ? createStallRuntime(opsxConfig.stallEscalation) : null,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
return routingState;
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Routing runtime handed to computeSelectionSync (design D8.5). All
|
|
974
|
+
* switches off + balanced → `undefined` (the zero-drift contract: the
|
|
975
|
+
* selection path never even sees a routing object). `explicit` marks the
|
|
976
|
+
* /pick-model command paths — guard bypassed, no fresh classification,
|
|
977
|
+
* band annotations suppressed; the session band still drives offsets.
|
|
978
|
+
*/
|
|
979
|
+
const routingRuntimeFor = (ctx: ExtensionContext, explicit: boolean): RoutingRuntime | undefined => {
|
|
980
|
+
const qp = opsxConfig.qualityPreference;
|
|
981
|
+
const anyOn = opsxConfig.difficultyRouting.enabled
|
|
982
|
+
|| opsxConfig.stallEscalation.enabled
|
|
983
|
+
|| opsxConfig.continuityGuard.enabled
|
|
984
|
+
|| qp !== 'balanced';
|
|
985
|
+
if (!anyOn) return undefined;
|
|
986
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
987
|
+
return {
|
|
988
|
+
band: st.band,
|
|
989
|
+
confidence: st.confidence,
|
|
990
|
+
stallActive: st.stallActive,
|
|
991
|
+
explicit,
|
|
992
|
+
qualityPreference: qp,
|
|
993
|
+
// Incumbent read BEFORE the recompute reassigns `selection` — the
|
|
994
|
+
// pin decision compares against the previous context only.
|
|
995
|
+
guard: opsxConfig.continuityGuard.enabled && !explicit
|
|
996
|
+
? { weight: opsxConfig.continuityGuard.weight, incumbent: selection, priorBand: selection?.routingBand ?? null }
|
|
997
|
+
: undefined,
|
|
998
|
+
};
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// Same-account regional variant state (china-config): resolved fresh at
|
|
1002
|
+
// every recompute path from the live pool composition — no cache and
|
|
1003
|
+
// therefore no invalidation problem. The result feeds both
|
|
1004
|
+
// computeSelectionSync and the health key so the two cannot drift.
|
|
1005
|
+
// Judgment = variant-table membership + both sides in the effective pool
|
|
1006
|
+
// + strictly equal API keys (same-account proof; unequal/missing keys →
|
|
1007
|
+
// both doors coexist).
|
|
1008
|
+
const resolveVariantState = async (
|
|
1009
|
+
models: { provider: string; id: string }[] | null | undefined,
|
|
1010
|
+
getApiKey: ((provider: string) => Promise<string | undefined>) | undefined,
|
|
1011
|
+
reach: { unreachable?: Set<string>; availableByProvider?: Map<string, string[] | null> } = {},
|
|
1012
|
+
): Promise<VariantCollapseResult> => {
|
|
1013
|
+
const idle: VariantCollapseResult = { collapsedProviders: new Set<string>(), dropped: null, kept: null };
|
|
1014
|
+
if (!models || models.length === 0) return idle;
|
|
1015
|
+
// Trigger condition evaluates on the EFFECTIVE pool (spec: 双变体同时
|
|
1016
|
+
// 在池才触发——reachability / 静态排除 / provider_models 收窄 /
|
|
1017
|
+
// allowlist 已滤掉一侧时不触发): the same provider-level filters
|
|
1018
|
+
// computeSelectionSync applies to basePool (minus the collapse
|
|
1019
|
+
// itself), plus the allowlist reduction. provider_models participates:
|
|
1020
|
+
// a kept side narrowed to zero must not count as "in pool", or the
|
|
1021
|
+
// collapse would drop the other side and leave the account unservable.
|
|
1022
|
+
const providerModelMap = Object.keys(opsxConfig.providerModels).length > 0
|
|
1023
|
+
? new Map(Object.entries(opsxConfig.providerModels))
|
|
1024
|
+
: undefined;
|
|
1025
|
+
const providersInPool = new Set(
|
|
1026
|
+
filterSelectableModels(models, {
|
|
1027
|
+
blockedSelectors: regionBlockedModels,
|
|
1028
|
+
unreachableProviders: reach.unreachable,
|
|
1029
|
+
availableByProvider: reach.availableByProvider,
|
|
1030
|
+
excludedProviders: opsxConfig.excluded_providers,
|
|
1031
|
+
providerModels: providerModelMap,
|
|
1032
|
+
})
|
|
1033
|
+
.filter((m) => providerSurvivesAllowlist(m.provider, m.id, opsxConfig.modelAllowlist))
|
|
1034
|
+
.map((m) => m.provider),
|
|
1035
|
+
);
|
|
1036
|
+
return resolveVariantCollapse({
|
|
1037
|
+
china: opsxConfig.china,
|
|
1038
|
+
providersInPool,
|
|
1039
|
+
getApiKey: await resolveVariantKeyLookup(providersInPool, getApiKey),
|
|
1040
|
+
});
|
|
1041
|
+
};
|
|
588
1042
|
|
|
589
1043
|
const buildSelectionHealthKey = (
|
|
590
1044
|
health: Map<string, ProviderHealth> | null,
|
|
591
1045
|
reach: { unreachable: Set<string>; availableByProvider: Map<string, string[] | null> },
|
|
592
1046
|
fc: FamilyConstraint | null,
|
|
1047
|
+
collapsed: ReadonlySet<string> = new Set<string>(),
|
|
593
1048
|
): string => {
|
|
594
|
-
return selectionHealthKeyFor(health, reach, fc, regionBlockedModels);
|
|
1049
|
+
return selectionHealthKeyFor(health, reach, fc, regionBlockedModels, collapsed);
|
|
595
1050
|
};
|
|
596
1051
|
|
|
597
1052
|
const formatFamilyConstraint = (fc: FamilyConstraint | null): string => {
|
|
598
|
-
if (!fc) return '';
|
|
599
1053
|
const parts: string[] = [];
|
|
600
|
-
if (fc
|
|
601
|
-
|
|
1054
|
+
if (fc?.selectors && fc.selectors.length > 0) parts.push(`selectors=${fc.selectors.join(',')}`);
|
|
1055
|
+
// china=on when the session constraint carries the exclusion OR the
|
|
1056
|
+
// config default is on — the config-derived segment renders even
|
|
1057
|
+
// without any selector constraint (spec: 配置派生 china=on 无约束也显示).
|
|
1058
|
+
if ((fc?.excludeSelectors.length ?? 0) > 0 || effectiveConfig.china) parts.push('china=on');
|
|
1059
|
+
if (fc?.roles && fc.roles.length > 0) parts.push(`scope=${fc.roles.join(',')}`);
|
|
602
1060
|
return parts.join(' · ');
|
|
603
1061
|
};
|
|
604
1062
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
1063
|
+
/**
|
|
1064
|
+
* Copy of opsxConfig with the constrained roles forced to auto — covers
|
|
1065
|
+
* pinned roles. `scopedSlots === null` = global constraint (every role
|
|
1066
|
+
* auto, status-quo shape); scoped constraints only force the roles whose
|
|
1067
|
+
* consumed tier slot is in scope (coder feeds mid, the rest high) —
|
|
1068
|
+
* out-of-scope roles keep their opsx.yml value, so synthPinnedResult
|
|
1069
|
+
* keeps their pin and auto roles draw the unconstrained pool. The no-arg
|
|
1070
|
+
* poller/assembly call sites keep the all-auto semantics.
|
|
1071
|
+
*/
|
|
1072
|
+
const buildEffectiveConfig = (scopedSlots: ReadonlySet<TierName> | null = null): ResolvedOpsxConfig => {
|
|
607
1073
|
const roles = {} as Record<Role, RoleValue>;
|
|
608
|
-
for (const role of OPSX_ROLES)
|
|
1074
|
+
for (const role of OPSX_ROLES) {
|
|
1075
|
+
const slot: TierName = role === 'coder' ? 'mid' : 'high';
|
|
1076
|
+
roles[role] = scopedSlots === null || scopedSlots.has(slot) ? { kind: 'auto' } : opsxConfig.roles[role];
|
|
1077
|
+
}
|
|
609
1078
|
return { ...opsxConfig, roles };
|
|
610
1079
|
};
|
|
611
1080
|
const startPollerFor = async (ctx: ExtensionContext, onTick?: () => void): Promise<void> => {
|
|
@@ -630,7 +1099,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
630
1099
|
});
|
|
631
1100
|
};
|
|
632
1101
|
|
|
633
|
-
const ensureSelection = (ctx: ExtensionContext, force = false): Promise<SelectionContext> => {
|
|
1102
|
+
const ensureSelection = (ctx: ExtensionContext, force = false, explicit = false): Promise<SelectionContext> => {
|
|
634
1103
|
if (selectionPromise && !force) return selectionPromise;
|
|
635
1104
|
selectionPromise = (async () => {
|
|
636
1105
|
await startPollerFor(ctx);
|
|
@@ -644,7 +1113,11 @@ export default (pi: ExtensionAPI) => {
|
|
|
644
1113
|
firstUnreachableProviders = reach.unreachable;
|
|
645
1114
|
availableByProvider = reach.availableByProvider;
|
|
646
1115
|
const health = getSharedUsage()?.health ?? null;
|
|
647
|
-
const
|
|
1116
|
+
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
1117
|
+
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
1118
|
+
: undefined;
|
|
1119
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1120
|
+
const key = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
648
1121
|
if (!force && selection && selectionHealthKey === key) return selection;
|
|
649
1122
|
// Thread effectiveConfig + familyConstraint so family/pinned constraints
|
|
650
1123
|
// survive this recompute (ensureSelection is one of the shared recompute paths).
|
|
@@ -652,11 +1125,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
652
1125
|
effectiveConfig,
|
|
653
1126
|
models,
|
|
654
1127
|
health,
|
|
655
|
-
currentKnownRoleIds(),
|
|
656
1128
|
regionBlockedModels,
|
|
657
1129
|
reach.unreachable,
|
|
658
1130
|
reach.availableByProvider,
|
|
659
1131
|
familyConstraint,
|
|
1132
|
+
collapsed,
|
|
1133
|
+
warn,
|
|
1134
|
+
routingRuntimeFor(ctx, explicit),
|
|
660
1135
|
);
|
|
661
1136
|
selection = sel;
|
|
662
1137
|
selectionHealthKey = key;
|
|
@@ -688,6 +1163,35 @@ export default (pi: ExtensionAPI) => {
|
|
|
688
1163
|
// would be overwritten by whichever instance starts last.
|
|
689
1164
|
let recordsOwnUsage = false;
|
|
690
1165
|
|
|
1166
|
+
// ── adaptive provider concurrency: host seams ─────────────────
|
|
1167
|
+
/** Host settings port for the control loop's hot-apply (narrowed to `override`). */
|
|
1168
|
+
const hostSettingsPort = (): SettingsPort | null => {
|
|
1169
|
+
const settings = pi.pi?.settings as unknown as { override?: unknown } | undefined;
|
|
1170
|
+
return settings && typeof settings.override === 'function' ? (settings as SettingsPort) : null;
|
|
1171
|
+
};
|
|
1172
|
+
/** Config root + agent dir for the control loop (host settings, else `~/.omp/agent`). */
|
|
1173
|
+
const resolveConcurrencyPaths = (): { agentDir: string; configPath: string } => {
|
|
1174
|
+
let agentDir = '';
|
|
1175
|
+
try {
|
|
1176
|
+
agentDir = (pi.pi?.settings as { getAgentDir?: () => string } | undefined)?.getAgentDir?.() ?? '';
|
|
1177
|
+
} catch {
|
|
1178
|
+
agentDir = '';
|
|
1179
|
+
}
|
|
1180
|
+
if (!agentDir) agentDir = join(homedir(), '.omp', 'agent');
|
|
1181
|
+
return { agentDir, configPath: resolveGlobalConfigPath(agentDir) ?? join(agentDir, 'config.yml') };
|
|
1182
|
+
};
|
|
1183
|
+
/** The existing Redis channel as the ① aggregation transport (no new command surface). */
|
|
1184
|
+
const aggregationRedisPort = (cfg: ResolvedOpsxConfig): ConcurrencyRedisPort | null => {
|
|
1185
|
+
if (!cfg.redis_enabled) return null;
|
|
1186
|
+
const client = new RedisClient({
|
|
1187
|
+
host: cfg.redis_host,
|
|
1188
|
+
port: cfg.redis_port,
|
|
1189
|
+
connectTimeoutMs: cfg.redis_connect_timeout_ms,
|
|
1190
|
+
commandTimeoutMs: cfg.redis_command_timeout_ms,
|
|
1191
|
+
});
|
|
1192
|
+
return { pipeline: (commands) => client.pipeline(commands) };
|
|
1193
|
+
};
|
|
1194
|
+
|
|
691
1195
|
// ── shared usage poller + widget ──────────────────────────────
|
|
692
1196
|
pi.on('session_start', async (_event, ctx) => {
|
|
693
1197
|
try {
|
|
@@ -821,8 +1325,23 @@ export default (pi: ExtensionAPI) => {
|
|
|
821
1325
|
// Last unix second already sampled by onTick (per session). Drives
|
|
822
1326
|
// the catch-up loop that backfills every skipped second with zeros.
|
|
823
1327
|
let lastSampledSec: number | undefined;
|
|
824
|
-
const render = () => {
|
|
825
|
-
const
|
|
1328
|
+
const render = async () => {
|
|
1329
|
+
const resolved = resolveActiveProviders();
|
|
1330
|
+
// Same-account regional collapse on the usage bar (china-config):
|
|
1331
|
+
// a collapsed side is a duplicate door into the quota the kept
|
|
1332
|
+
// side already displays, so dropping its row loses nothing.
|
|
1333
|
+
// In-pool for usage = usage data or credentials present (the
|
|
1334
|
+
// `active` union) — model candidates are not required. The
|
|
1335
|
+
// strict key-equality gate matches the pool path: unequal or
|
|
1336
|
+
// missing keys keep BOTH rows (labels 智谱 / Z.AI distinguish).
|
|
1337
|
+
const activeSet = new Set(resolved.active);
|
|
1338
|
+
const collapsed = resolveVariantCollapse({
|
|
1339
|
+
china: opsxConfig.china,
|
|
1340
|
+
providersInPool: activeSet,
|
|
1341
|
+
getApiKey: await resolveVariantKeyLookup(activeSet, getApiKey),
|
|
1342
|
+
}).collapsedProviders;
|
|
1343
|
+
const { reports, placeholderIds, active } = collapseUsageView(resolved.reports, resolved.placeholderIds, resolved.active, collapsed);
|
|
1344
|
+
const show = resolved.show;
|
|
826
1345
|
const state = getSharedUsage();
|
|
827
1346
|
const loadingProviders = state?.loadingProviders ?? new Set<string>();
|
|
828
1347
|
if (!show) {
|
|
@@ -852,14 +1371,28 @@ export default (pi: ExtensionAPI) => {
|
|
|
852
1371
|
for (const p of active) {
|
|
853
1372
|
consumptionTracks.set(p, { samples: getConsumptionSamples(p) });
|
|
854
1373
|
}
|
|
1374
|
+
// Adaptive-concurrency inline tokens: the controller's per-provider
|
|
1375
|
+
// view (`stateFor`) rendered as `L/ceiling` right after the provider
|
|
1376
|
+
// name on BOTH render paths. Providers the controller does not manage
|
|
1377
|
+
// stay absent from the map — and an empty map keeps the widget
|
|
1378
|
+
// byte-identical to the pre-token output.
|
|
1379
|
+
const concurrencyViews = new Map<string, ConcurrencyInlineView>();
|
|
1380
|
+
if (ownedConcurrency) {
|
|
1381
|
+
const degraded = ownedConcurrency.controller.leaseMode() === 'readonly';
|
|
1382
|
+
for (const p of active) {
|
|
1383
|
+
const view = ownedConcurrency.controller.stateFor(p);
|
|
1384
|
+
if (view) concurrencyViews.set(p, { limit: view.limit, ceiling: view.ceiling, degraded });
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const inlineConcurrency = concurrencyViews.size > 0 ? concurrencyViews : undefined;
|
|
855
1388
|
if (ctx.hasUI) {
|
|
856
|
-
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders()), {
|
|
1389
|
+
ctx.ui.setWidget(key, createUsageWidget(reports, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency), {
|
|
857
1390
|
placement: 'belowEditor',
|
|
858
1391
|
});
|
|
859
1392
|
} else {
|
|
860
1393
|
if (ctx.ui) {
|
|
861
1394
|
const cols = (process.stdout.columns ?? 120) - 3;
|
|
862
|
-
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders());
|
|
1395
|
+
const lines = renderUsageReports(reports, cols, undefined, placeholderIds, loadingProviders, consumptionTracks, usageEstimates, getExhaustedProviders(), inlineConcurrency);
|
|
863
1396
|
ctx.ui.setWidget(key, lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
|
|
864
1397
|
}
|
|
865
1398
|
}
|
|
@@ -870,8 +1403,41 @@ export default (pi: ExtensionAPI) => {
|
|
|
870
1403
|
// subagent via cache backflow), and re-renders the widget. It never
|
|
871
1404
|
// recomputes a selection, never applies overrides, never setModel.
|
|
872
1405
|
const cfg = buildEffectiveConfig();
|
|
1406
|
+
// ── claim the process-wide adaptive-concurrency control loop ──
|
|
1407
|
+
// Only the owning session starts it (same rule as the render tick
|
|
1408
|
+
// above), and the module-level `ownedConcurrency` gate keeps a
|
|
1409
|
+
// subagent binding — or a re-entrant session_start — from starting a
|
|
1410
|
+
// second loop and lease against the same config root. The
|
|
1411
|
+
// `concurrency_enabled` check also short-circuits here so the
|
|
1412
|
+
// disabled path allocates nothing (no transport, no config read).
|
|
1413
|
+
if (claimsOwnership && ownedConcurrency === null && cfg.concurrency_enabled) {
|
|
1414
|
+
const paths = resolveConcurrencyPaths();
|
|
1415
|
+
const controller = createConcurrencyControllerIfEnabled(cfg, {
|
|
1416
|
+
configPath: paths.configPath,
|
|
1417
|
+
agentDir: paths.agentDir,
|
|
1418
|
+
settings: hostSettingsPort(),
|
|
1419
|
+
redis: concurrencyRedisPortForTests !== undefined ? concurrencyRedisPortForTests : aggregationRedisPort(cfg),
|
|
1420
|
+
warn,
|
|
1421
|
+
});
|
|
1422
|
+
if (controller) {
|
|
1423
|
+
ownedConcurrency = {
|
|
1424
|
+
sessionId: ctx.sessionManager?.getSessionId?.() ?? '',
|
|
1425
|
+
controller,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
873
1429
|
const onTick = () => {
|
|
874
|
-
//
|
|
1430
|
+
// Adaptive concurrency rides the SAME render tick — it owns no
|
|
1431
|
+
// timer; the controller's absolute-time gate (`concurrency_eval_
|
|
1432
|
+
// interval_ms`) decides whether this tick evaluates.
|
|
1433
|
+
const controller = ownedConcurrency?.controller;
|
|
1434
|
+
if (controller) {
|
|
1435
|
+
void controller.onTick().catch((e) => warn(`[omp-opsx-addon] concurrency tick error: ${e}`));
|
|
1436
|
+
}
|
|
1437
|
+
// Fire-and-forget status probe: do not block the tick. Deliberately
|
|
1438
|
+
// NOT filtered by the regional collapse (china-config): probing the
|
|
1439
|
+
// collapsed side keeps its reachability record fresh, so the
|
|
1440
|
+
// selection-pool premise of the collapse never rides stale data.
|
|
875
1441
|
const { placeholderIds, active } = resolveActiveProviders();
|
|
876
1442
|
let models: { id: string; provider: string; baseUrl?: string }[] = [];
|
|
877
1443
|
try {
|
|
@@ -897,7 +1463,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
897
1463
|
timeoutMs: cfg.probe_timeout_ms,
|
|
898
1464
|
excluded: cfg.excluded_providers,
|
|
899
1465
|
},
|
|
900
|
-
}).then((written) => { if (written > 0) render(); }).catch(() => {});
|
|
1466
|
+
}).then((written) => { if (written > 0) void render().catch(() => {}); }).catch(() => {});
|
|
901
1467
|
|
|
902
1468
|
sessionUsageRecorder.flush();
|
|
903
1469
|
// Waveform source = per-second consumption sequence (timestamped
|
|
@@ -923,7 +1489,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
923
1489
|
}
|
|
924
1490
|
}
|
|
925
1491
|
lastSampledSec = lastSec;
|
|
926
|
-
render();
|
|
1492
|
+
void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
|
|
927
1493
|
};
|
|
928
1494
|
await startSharedPoller(auth, DIRECT_FETCHERS, getApiKey, {
|
|
929
1495
|
onTick: claimsOwnership ? onTick : undefined,
|
|
@@ -945,10 +1511,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
945
1511
|
// exposes no `signal`, so per-session abort cleanup never fires —
|
|
946
1512
|
// ownership above, not teardown hooks, keeps the singletons sane.)
|
|
947
1513
|
if (activeResizeHandler) process.stdout.off('resize', activeResizeHandler);
|
|
948
|
-
const onResize = () => render();
|
|
1514
|
+
const onResize = () => void render().catch(() => {});
|
|
949
1515
|
activeResizeHandler = onResize;
|
|
950
1516
|
process.stdout.on('resize', onResize);
|
|
951
|
-
render();
|
|
1517
|
+
void render().catch((e) => warn(`[omp-opsx-addon] usage render error: ${e}`));
|
|
952
1518
|
} catch (e) {
|
|
953
1519
|
warn(`[omp-opsx-addon] usage poller error: ${e}`);
|
|
954
1520
|
}
|
|
@@ -1046,25 +1612,76 @@ export default (pi: ExtensionAPI) => {
|
|
|
1046
1612
|
}
|
|
1047
1613
|
}
|
|
1048
1614
|
}
|
|
1615
|
+
// The control loop belongs to ONE session: only the owner's own exit
|
|
1616
|
+
// stops it. A subagent's shutdown MUST NOT tear down the root's loop
|
|
1617
|
+
// and release its lease (identity gate, see `ownedConcurrency`).
|
|
1618
|
+
if (ownedConcurrency && sid === ownedConcurrency.sessionId) {
|
|
1619
|
+
const owned = ownedConcurrency;
|
|
1620
|
+
ownedConcurrency = null;
|
|
1621
|
+
try {
|
|
1622
|
+
await owned.controller.shutdown();
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
warn(`[omp-opsx-addon] concurrency shutdown error: ${e}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1049
1627
|
} catch {
|
|
1050
1628
|
/* best-effort */
|
|
1051
1629
|
}
|
|
1052
1630
|
});
|
|
1053
1631
|
// ── session lifecycle ─────────────────────────────────────────
|
|
1054
|
-
// Pick-model
|
|
1632
|
+
// Pick-model stays manual (`/pick-model refresh`); with autoselect_on_start
|
|
1633
|
+
// (default true) the session_start handler below additionally applies the
|
|
1634
|
+
// configured selection once at startup, so it already holds for turn one.
|
|
1055
1635
|
const changeScanner = createCachedScanner(30_000);
|
|
1056
1636
|
|
|
1637
|
+
// ── W4 routing segment (change: difficulty-routed-selection) ──
|
|
1638
|
+
// The gate wraps ONLY this segment: the handler's existing body below
|
|
1639
|
+
// (systemPrompt injection + change scanning) runs unchanged under every
|
|
1640
|
+
// switch combination — gating never retires the whole handler (P1-1).
|
|
1641
|
+
// Both gates mirror the existing instance rules: the feature switches
|
|
1642
|
+
// (both off → straight out, zero behavior) and the main-instance rule
|
|
1643
|
+
// (subagent bindings never classify or sample). The band/stall comparison
|
|
1644
|
+
// decides whether one force recompute happens this turn; otherwise the
|
|
1645
|
+
// existing health-key caching applies untouched.
|
|
1646
|
+
const runRoutingSegment = async (event: { prompt: string }, ctx: ExtensionContext): Promise<void> => {
|
|
1647
|
+
if (!opsxConfig.difficultyRouting.enabled && !opsxConfig.stallEscalation.enabled) return;
|
|
1648
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1649
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
1650
|
+
const classification = opsxConfig.difficultyRouting.enabled
|
|
1651
|
+
? classifyTaskSafe(event.prompt)
|
|
1652
|
+
: { band: 'standard' as TaskBand, confidence: 0 };
|
|
1653
|
+
// Stall episode evaluation for this turn (design D4: anchored on the
|
|
1654
|
+
// newest signature, escalated latches once per episode). The window
|
|
1655
|
+
// content is maintained by the tool_execution_start handler below.
|
|
1656
|
+
let stallActive = false;
|
|
1657
|
+
if (opsxConfig.stallEscalation.enabled && st.stall) {
|
|
1658
|
+
stallActive = evaluateStall(st.stall).active;
|
|
1659
|
+
}
|
|
1660
|
+
const prevBand = st.band;
|
|
1661
|
+
const prevStallActive = st.stallActive;
|
|
1662
|
+
st.band = classification.band;
|
|
1663
|
+
st.confidence = classification.confidence;
|
|
1664
|
+
st.stallActive = stallActive;
|
|
1665
|
+
const bandMoved = opsxConfig.difficultyRouting.enabled && prevBand !== classification.band;
|
|
1666
|
+
const stallMoved = opsxConfig.stallEscalation.enabled && prevStallActive !== stallActive;
|
|
1667
|
+
if (bandMoved || stallMoved) await ensureSelection(ctx, true);
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1057
1670
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
1058
1671
|
try {
|
|
1672
|
+
// Best-effort isolation (review P2): the segment's force-recompute
|
|
1673
|
+
// awaits poller/Redis and cache-fs paths absent from this handler
|
|
1674
|
+
// pre-W4; a rejection must never reach the outer catch and strip
|
|
1675
|
+
// STATIC_PROMPT + the change-scan segment for the turn.
|
|
1676
|
+
try {
|
|
1677
|
+
await runRoutingSegment(event, ctx);
|
|
1678
|
+
} catch (segErr) {
|
|
1679
|
+
warn(`[omp-opsx-addon] routing segment error: ${segErr}`);
|
|
1680
|
+
}
|
|
1059
1681
|
// This fallback call is intentionally NOT threaded with familyConstraint:
|
|
1060
1682
|
// it prefers the cached `selection`, and the fallback passes models=null so
|
|
1061
1683
|
// 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
|
-
);
|
|
1684
|
+
const sel = selection ?? computeSelectionSync(opsxConfig, null, null);
|
|
1068
1685
|
const changes = await changeScanner.scanChanges(directory, 'doing');
|
|
1069
1686
|
// Layered injection: STATIC_PROMPT is byte-identical every turn → prefix cache
|
|
1070
1687
|
// hit for [omp-base + skills + static]. Only the dynamic tail re-prefills.
|
|
@@ -1087,11 +1704,41 @@ export default (pi: ExtensionAPI) => {
|
|
|
1087
1704
|
frontmatter('planner'),
|
|
1088
1705
|
frontmatter('proposal-reviewer'),
|
|
1089
1706
|
);
|
|
1090
|
-
warn('[omp-opsx-addon] agents ready (
|
|
1707
|
+
warn('[omp-opsx-addon] agents ready (selection auto-applies at session start unless autoselect_on_start: false; /pick-model to re-run)');
|
|
1091
1708
|
} catch (e) {
|
|
1092
1709
|
warn('[omp-opsx-addon] installAgents error: ' + e);
|
|
1093
1710
|
}
|
|
1094
1711
|
});
|
|
1712
|
+
|
|
1713
|
+
// Session-start autoselect: apply the configured selection once so the
|
|
1714
|
+
// first turn already follows it — role overrides through the same apply
|
|
1715
|
+
// path as /pick-model, and the main session moved to the reviewer pick
|
|
1716
|
+
// (no-op when already equal; undefined pick → skip). The once-flag is also
|
|
1717
|
+
// what keeps a later in-session /model switch authoritative: this path
|
|
1718
|
+
// never runs again for the instance, so nothing rewrites the user's
|
|
1719
|
+
// choice. Owner gate before the once-flag: subagent bindings re-run the
|
|
1720
|
+
// factory with independent closures (a closure once-flag cannot see
|
|
1721
|
+
// them), so the process-level owner rule decides — same rule as the
|
|
1722
|
+
// usage/render claim in the first handler. Awaited, never fire-and-forget:
|
|
1723
|
+
// a detached apply would race the user's first prompt onto the native
|
|
1724
|
+
// model. Pins (opsx.yml agents.*) bypass selection via synthPinnedResult
|
|
1725
|
+
// and are never touched here.
|
|
1726
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1727
|
+
if (!opsxConfig.autoselectOnStart) return;
|
|
1728
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1729
|
+
if (autoApplied) return;
|
|
1730
|
+
autoApplied = true;
|
|
1731
|
+
try {
|
|
1732
|
+
const sel = await ensureSelection(ctx);
|
|
1733
|
+
applyRoleModel(sel);
|
|
1734
|
+
const picked = sel.selectorResults.reviewer?.picked;
|
|
1735
|
+
if (picked && ctx.model && `${ctx.model.provider}/${ctx.model.id}` !== `${picked.provider}/${picked.id}`) {
|
|
1736
|
+
await pi.setModel(picked);
|
|
1737
|
+
}
|
|
1738
|
+
} catch (e) {
|
|
1739
|
+
warn(`[omp-opsx-addon] session-start autoselect error: ${e}`);
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1095
1742
|
// ── Diagnostic-only error path (429/403; never auto-switches) ─
|
|
1096
1743
|
const diagnoseProviderError = async (ctx: ExtensionContext, source: string, errorType: '429' | '403'): Promise<void> => {
|
|
1097
1744
|
const currentModel = ctx.model;
|
|
@@ -1105,6 +1752,10 @@ export default (pi: ExtensionAPI) => {
|
|
|
1105
1752
|
// without a usage fetcher — record it in the process-local binary status
|
|
1106
1753
|
// so the placeholder column reads 耗尽 (see lib/provider-status.ts).
|
|
1107
1754
|
markExhausted(canonical);
|
|
1755
|
+
// Adaptive-concurrency signal ①: hand the observed explicit failure to
|
|
1756
|
+
// the control loop as a COUNTED event only. The diagnosis text and the
|
|
1757
|
+
// "never auto-switch provider" policy below are unchanged (task 6.5).
|
|
1758
|
+
ownedConcurrency?.controller.noteExplicitFailure(canonical, errorType);
|
|
1108
1759
|
// Force-refresh just this provider (bypassing the 10s floor) so the
|
|
1109
1760
|
// diagnosis reflects current quota.
|
|
1110
1761
|
if (auth) await pokeProvider(auth, DIRECT_FETCHERS, getApiKey, { providers: [canonical], force: true });
|
|
@@ -1148,6 +1799,44 @@ export default (pi: ExtensionAPI) => {
|
|
|
1148
1799
|
warn(`[omp-opsx-addon] tool_execution_end error: ${e}`);
|
|
1149
1800
|
}
|
|
1150
1801
|
});
|
|
1802
|
+
// ── W4 stall sampling (change: difficulty-routed-selection; read-only) ──
|
|
1803
|
+
// Freshly registered handlers (unlike before_agent_start, which predates
|
|
1804
|
+
// W4): the first-line switch gate IS the whole disabled behavior — zero
|
|
1805
|
+
// sampling, zero state writes. Main-instance gate mirrors autoselect:
|
|
1806
|
+
// subagent bindings never sample (design D4). The window only shapes the
|
|
1807
|
+
// NEXT selection recompute's inputs; nothing here switches a live model
|
|
1808
|
+
// and the 429/403 diagnostics above are untouched (T4).
|
|
1809
|
+
pi.on('tool_execution_start', async (event, ctx) => {
|
|
1810
|
+
if (!opsxConfig.stallEscalation.enabled) return;
|
|
1811
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1812
|
+
try {
|
|
1813
|
+
const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
1814
|
+
const st = routingStateFor(sessionId);
|
|
1815
|
+
if (!st.stall) return;
|
|
1816
|
+
recordToolCall(st.stall, sessionId, toolSignature(event.toolName, event.args));
|
|
1817
|
+
} catch {
|
|
1818
|
+
/* stall sampling is best-effort; stay silent */
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
pi.on('turn_end', async (_event, ctx) => {
|
|
1822
|
+
if (!opsxConfig.stallEscalation.enabled) return;
|
|
1823
|
+
if (!(ctx.hasUI || !hasTickOwner())) return;
|
|
1824
|
+
try {
|
|
1825
|
+
const st = routingStateFor(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
1826
|
+
if (!st.stall) return;
|
|
1827
|
+
// Transcription fallback (design D4): the event stream is the
|
|
1828
|
+
// primary source; when it delivered nothing for this session
|
|
1829
|
+
// (process reload), rebuild the near window from the read-only
|
|
1830
|
+
// transcript. Empty/corrupt entries idle the feature — never a
|
|
1831
|
+
// downgrade, no warn noise into the selection path.
|
|
1832
|
+
if (st.stall.signatures.length === 0) {
|
|
1833
|
+
rebuildFromEntries(st.stall, ctx.sessionManager.getEntries());
|
|
1834
|
+
}
|
|
1835
|
+
evaluateStall(st.stall);
|
|
1836
|
+
} catch {
|
|
1837
|
+
/* stall evaluation is best-effort; stay silent */
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1151
1840
|
pi.on('input', async (event) => {
|
|
1152
1841
|
const match = event.text.match(/^@(coder|reviewer|planner|proposal-reviewer|code-reviewer)\b(.*)$/i);
|
|
1153
1842
|
if (!match) return;
|
|
@@ -1165,12 +1854,13 @@ export default (pi: ExtensionAPI) => {
|
|
|
1165
1854
|
|
|
1166
1855
|
pi.setLabel('pick-model', '模型选择');
|
|
1167
1856
|
pi.registerCommand('pick-model', {
|
|
1168
|
-
description: '模型管理:choices 展示 / refresh 重选 / update 更新档位 / <selector> [--china]
|
|
1857
|
+
description: '模型管理:choices 展示 / refresh 重选 / update 更新档位 / [roleList:]<selector> [--china] 按 selector 约束(可 role 定向,如 smol:glm*;词表 smol/default/…+agent 别名/all;裸词·glob·provider/id 双段)/ reset 清除约束',
|
|
1169
1858
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1170
1859
|
try {
|
|
1860
|
+
const symbols = resolveSymbols(pi.pi?.settings);
|
|
1171
1861
|
const parsed = parseAutoModelArgs(args);
|
|
1172
1862
|
if (parsed.kind === 'error') {
|
|
1173
|
-
pi.sendMessage({ customType: 'pick-model', content:
|
|
1863
|
+
pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${parsed.message}`, display: true }, { triggerTurn: false });
|
|
1174
1864
|
return;
|
|
1175
1865
|
}
|
|
1176
1866
|
|
|
@@ -1178,8 +1868,8 @@ export default (pi: ExtensionAPI) => {
|
|
|
1178
1868
|
if (parsed.kind === 'update') {
|
|
1179
1869
|
const result = updateTiers();
|
|
1180
1870
|
const msg = result.ok
|
|
1181
|
-
?
|
|
1182
|
-
:
|
|
1871
|
+
? `${symbols.success} Tiers data updated: ${result.count} entries`
|
|
1872
|
+
: `${symbols.error} Update failed: ${result.error}`;
|
|
1183
1873
|
pi.sendMessage({ customType: 'pick-model', content: msg, display: true }, { triggerTurn: false });
|
|
1184
1874
|
return;
|
|
1185
1875
|
}
|
|
@@ -1196,15 +1886,20 @@ export default (pi: ExtensionAPI) => {
|
|
|
1196
1886
|
firstUnreachableProviders = reach.unreachable;
|
|
1197
1887
|
availableByProvider = reach.availableByProvider;
|
|
1198
1888
|
const health = getSharedUsage()?.health ?? null;
|
|
1889
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1199
1890
|
const freshSel = computeSelectionSync(
|
|
1200
1891
|
effectiveConfig,
|
|
1201
1892
|
models,
|
|
1202
1893
|
health,
|
|
1203
|
-
currentKnownRoleIds(),
|
|
1204
1894
|
regionBlockedModels,
|
|
1205
1895
|
reach.unreachable,
|
|
1206
1896
|
reach.availableByProvider,
|
|
1207
1897
|
familyConstraint,
|
|
1898
|
+
collapsed,
|
|
1899
|
+
warn,
|
|
1900
|
+
// /pick-model refresh: explicit path (design D5) —
|
|
1901
|
+
// guard bypassed, no fresh classification.
|
|
1902
|
+
routingRuntimeFor(ctx, true),
|
|
1208
1903
|
);
|
|
1209
1904
|
const changes: string[] = [];
|
|
1210
1905
|
// Check primary session model
|
|
@@ -1215,7 +1910,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
1215
1910
|
const primaryBest = primaryResult?.candidates[0];
|
|
1216
1911
|
if (primaryBest && `${currentModel.provider}/${currentModel.id}` !== `${primaryBest.model.provider}/${primaryBest.model.id}`) {
|
|
1217
1912
|
await pi.setModel(primaryBest.model);
|
|
1218
|
-
changes.push(
|
|
1913
|
+
changes.push(formatChangeLine({
|
|
1914
|
+
name: '主会话',
|
|
1915
|
+
to: `${primaryBest.model.provider}/${primaryBest.model.id}`,
|
|
1916
|
+
coverage: coverageLabel(primaryBest.coverageClass),
|
|
1917
|
+
tier: tierLabel(primaryBest.tier),
|
|
1918
|
+
}));
|
|
1219
1919
|
}
|
|
1220
1920
|
}
|
|
1221
1921
|
// Check all agent models
|
|
@@ -1229,19 +1929,28 @@ export default (pi: ExtensionAPI) => {
|
|
|
1229
1929
|
if (roleCfg.kind === 'model' || roleCfg.kind === 'provider-model') {
|
|
1230
1930
|
const pinned = roleValueToModelString(roleCfg);
|
|
1231
1931
|
if (pinned && pinned !== pickedSel) {
|
|
1232
|
-
changes.push(
|
|
1932
|
+
changes.push(formatChangeLine({
|
|
1933
|
+
name: `${agent}(已指定 ${pinned})`,
|
|
1934
|
+
to: pickedSel,
|
|
1935
|
+
...annotationOfPicked(result, picked),
|
|
1936
|
+
kind: roleKindLabel(freshSel.roleKind[role]),
|
|
1937
|
+
}));
|
|
1233
1938
|
}
|
|
1234
1939
|
} else {
|
|
1235
|
-
changes.push(
|
|
1940
|
+
changes.push(formatChangeLine({
|
|
1941
|
+
name: agent,
|
|
1942
|
+
to: pickedSel,
|
|
1943
|
+
...annotationOfPicked(result, picked),
|
|
1944
|
+
kind: roleKindLabel(freshSel.roleKind[role]),
|
|
1945
|
+
}));
|
|
1236
1946
|
}
|
|
1237
1947
|
}
|
|
1238
1948
|
selection = freshSel;
|
|
1239
1949
|
applyRoleModel(freshSel);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1950
|
+
const content = changes.length > 0
|
|
1951
|
+
? renderPickModelReport('refresh', { action: '已刷新', constraint: formatFamilyConstraint(familyConstraint) || undefined, detail: [changes.join('\n')] }, symbols)
|
|
1952
|
+
: renderPickModelReport('refresh', { action: '已刷新 · 所有模型状态正常,无需切换' }, symbols);
|
|
1953
|
+
pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
|
|
1245
1954
|
return;
|
|
1246
1955
|
}
|
|
1247
1956
|
|
|
@@ -1259,15 +1968,20 @@ export default (pi: ExtensionAPI) => {
|
|
|
1259
1968
|
firstUnreachableProviders = reach.unreachable;
|
|
1260
1969
|
availableByProvider = reach.availableByProvider;
|
|
1261
1970
|
const health = getSharedUsage()?.health ?? null;
|
|
1971
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
1262
1972
|
const sel = computeSelectionSync(
|
|
1263
1973
|
effectiveConfig,
|
|
1264
1974
|
models,
|
|
1265
1975
|
health,
|
|
1266
|
-
currentKnownRoleIds(),
|
|
1267
1976
|
regionBlockedModels,
|
|
1268
1977
|
reach.unreachable,
|
|
1269
1978
|
reach.availableByProvider,
|
|
1270
1979
|
familyConstraint,
|
|
1980
|
+
collapsed,
|
|
1981
|
+
warn,
|
|
1982
|
+
// /pick-model reset: explicit path (design D5) —
|
|
1983
|
+
// guard bypassed, no fresh classification.
|
|
1984
|
+
routingRuntimeFor(ctx, true),
|
|
1271
1985
|
);
|
|
1272
1986
|
const changes: string[] = [];
|
|
1273
1987
|
const currentModel = ctx.model;
|
|
@@ -1275,7 +1989,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
1275
1989
|
if (newPrimary) {
|
|
1276
1990
|
const newSel = `${newPrimary.provider}/${newPrimary.id}`;
|
|
1277
1991
|
const oldSel = currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined;
|
|
1278
|
-
changes.push(
|
|
1992
|
+
changes.push(formatChangeLine({
|
|
1993
|
+
name: '主会话',
|
|
1994
|
+
from: oldSel && oldSel !== newSel ? oldSel : undefined,
|
|
1995
|
+
to: newSel,
|
|
1996
|
+
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
1997
|
+
}));
|
|
1279
1998
|
}
|
|
1280
1999
|
for (const role of OPSX_ROLES) {
|
|
1281
2000
|
const picked = sel.selectorResults[role]?.picked;
|
|
@@ -1283,17 +2002,24 @@ export default (pi: ExtensionAPI) => {
|
|
|
1283
2002
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
1284
2003
|
const prev = selection?.selectorResults[role]?.picked;
|
|
1285
2004
|
const oldSel = prev ? `${prev.provider}/${prev.id}` : undefined;
|
|
1286
|
-
changes.push(
|
|
2005
|
+
changes.push(formatChangeLine({
|
|
2006
|
+
name: AGENT_MAP[role],
|
|
2007
|
+
from: oldSel ?? '(none)',
|
|
2008
|
+
to: newSel,
|
|
2009
|
+
...annotationOfPicked(sel.selectorResults[role], picked),
|
|
2010
|
+
kind: roleKindLabel(sel.roleKind[role]),
|
|
2011
|
+
}));
|
|
1287
2012
|
}
|
|
1288
2013
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1289
2014
|
selection = sel;
|
|
1290
|
-
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
2015
|
+
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
1291
2016
|
applyRoleModel(sel);
|
|
1292
2017
|
pi.sendMessage({
|
|
1293
2018
|
customType: 'pick-model',
|
|
1294
|
-
content:
|
|
1295
|
-
?
|
|
1296
|
-
:
|
|
2019
|
+
content: renderPickModelReport('reset', {
|
|
2020
|
+
action: changes.length > 0 ? '已清除 selector 约束,恢复默认选择' : '已清除 selector 约束,恢复默认选择(无变化)',
|
|
2021
|
+
detail: changes.length > 0 ? [changes.join('\n')] : undefined,
|
|
2022
|
+
}, symbols),
|
|
1297
2023
|
display: true,
|
|
1298
2024
|
}, { triggerTurn: false });
|
|
1299
2025
|
return;
|
|
@@ -1312,6 +2038,24 @@ export default (pi: ExtensionAPI) => {
|
|
|
1312
2038
|
availableByProvider = reach.availableByProvider;
|
|
1313
2039
|
const health = getSharedUsage()?.health ?? null;
|
|
1314
2040
|
const excludeSelectors = parsed.china ? CHINA_EXCLUDE_FAMILIES : [];
|
|
2041
|
+
const collapsed = (await resolveVariantState(models, getApiKey, reach)).collapsedProviders;
|
|
2042
|
+
|
|
2043
|
+
// Fail fast on an inexpressible scope (model_role_tiers skip
|
|
2044
|
+
// sentinel) BEFORE touching any session state: a skip role
|
|
2045
|
+
// sits on no tier slot, so a pool constraint on it cannot be
|
|
2046
|
+
// expressed — never silently narrow the scope or no-op.
|
|
2047
|
+
let scopedSlots: ReadonlySet<TierName> | null = null;
|
|
2048
|
+
if (parsed.roleScope) {
|
|
2049
|
+
try {
|
|
2050
|
+
scopedSlots = scopedTierSlots(parsed.roleScope, opsxConfig.ompRoleTiers);
|
|
2051
|
+
} catch (e) {
|
|
2052
|
+
if (e instanceof ScopedRoleSkipError) {
|
|
2053
|
+
pi.sendMessage({ customType: 'pick-model', content: `${symbols.error} ${e.message}`, display: true }, { triggerTurn: false });
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
throw e;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
1315
2059
|
|
|
1316
2060
|
// Switch the session-scoped state BEFORE any recompute so every
|
|
1317
2061
|
// later recompute path (ensureSelection, 429/403 auto-switch,
|
|
@@ -1320,25 +2064,35 @@ export default (pi: ExtensionAPI) => {
|
|
|
1320
2064
|
// command restores it rather than silently dropping an active one.
|
|
1321
2065
|
const prevConstraint = familyConstraint;
|
|
1322
2066
|
const prevEffective = effectiveConfig;
|
|
1323
|
-
familyConstraint = { selectors: parsed.selectors, excludeSelectors };
|
|
1324
|
-
effectiveConfig = buildEffectiveConfig();
|
|
2067
|
+
familyConstraint = { selectors: parsed.selectors, excludeSelectors, roles: parsed.roleScope ?? null };
|
|
2068
|
+
effectiveConfig = buildEffectiveConfig(scopedSlots);
|
|
1325
2069
|
|
|
1326
2070
|
// Fail fast: an empty candidate set (or a role that cannot pick)
|
|
1327
2071
|
// is a hard error — never silently pick nothing or change models.
|
|
2072
|
+
// Mirror computeSelectionSync's pool shape exactly (variant
|
|
2073
|
+
// collapse on the provider axis + command exclude else standing
|
|
2074
|
+
// china default) so the precheck cannot pass on a pool the real
|
|
2075
|
+
// recompute would keep, or vice versa.
|
|
2076
|
+
const standingExclude = opsxConfig.china ? CHINA_EXCLUDE_FAMILIES : undefined;
|
|
1328
2077
|
const preFiltered = filterSelectableModels(models, {
|
|
1329
2078
|
blockedSelectors: regionBlockedModels,
|
|
1330
2079
|
unreachableProviders: reach.unreachable,
|
|
1331
|
-
excludedProviders: effectiveConfig.excluded_providers,
|
|
2080
|
+
excludedProviders: [...effectiveConfig.excluded_providers, ...collapsed],
|
|
1332
2081
|
availableByProvider: reach.availableByProvider,
|
|
1333
2082
|
selectors: parsed.selectors ?? undefined,
|
|
1334
|
-
excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors :
|
|
2083
|
+
excludeSelectors: excludeSelectors.length > 0 ? excludeSelectors : standingExclude,
|
|
2084
|
+
providerModels: Object.keys(effectiveConfig.providerModels).length > 0
|
|
2085
|
+
? new Map(Object.entries(effectiveConfig.providerModels))
|
|
2086
|
+
: undefined,
|
|
1335
2087
|
});
|
|
1336
2088
|
if (preFiltered.length === 0) {
|
|
1337
2089
|
familyConstraint = prevConstraint;
|
|
1338
2090
|
effectiveConfig = prevEffective;
|
|
1339
2091
|
pi.sendMessage({
|
|
1340
2092
|
customType: 'pick-model',
|
|
1341
|
-
content:
|
|
2093
|
+
content: renderPickModelReport('error', {
|
|
2094
|
+
action: `selector 约束下无可用模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
|
|
2095
|
+
}, symbols),
|
|
1342
2096
|
display: true,
|
|
1343
2097
|
}, { triggerTurn: false });
|
|
1344
2098
|
return;
|
|
@@ -1348,19 +2102,32 @@ export default (pi: ExtensionAPI) => {
|
|
|
1348
2102
|
effectiveConfig,
|
|
1349
2103
|
models,
|
|
1350
2104
|
health,
|
|
1351
|
-
currentKnownRoleIds(),
|
|
1352
2105
|
regionBlockedModels,
|
|
1353
2106
|
reach.unreachable,
|
|
1354
2107
|
reach.availableByProvider,
|
|
1355
2108
|
familyConstraint,
|
|
2109
|
+
collapsed,
|
|
2110
|
+
warn,
|
|
2111
|
+
// /pick-model <selector>: explicit path (design D5) —
|
|
2112
|
+
// guard bypassed, no fresh classification.
|
|
2113
|
+
routingRuntimeFor(ctx, true),
|
|
2114
|
+
);
|
|
2115
|
+
// pick-model-role-scope: only roles this command actually put
|
|
2116
|
+
// into the pool (roleKind 'auto' in the effective config) can
|
|
2117
|
+
// fail to pick. Out-of-scope pinned/omp-alias roles keep their
|
|
2118
|
+
// config value and synthPinnedResult never carries a picked —
|
|
2119
|
+
// counting them would roll back every scoped constraint.
|
|
2120
|
+
const emptyRole = OPSX_ROLES.find(
|
|
2121
|
+
(r) => sel.roleKind[r] === 'auto' && !sel.selectorResults[r]?.picked,
|
|
1356
2122
|
);
|
|
1357
|
-
const emptyRole = OPSX_ROLES.find((r) => !sel.selectorResults[r]?.picked);
|
|
1358
2123
|
if (emptyRole) {
|
|
1359
2124
|
familyConstraint = prevConstraint;
|
|
1360
2125
|
effectiveConfig = prevEffective;
|
|
1361
2126
|
pi.sendMessage({
|
|
1362
2127
|
customType: 'pick-model',
|
|
1363
|
-
content:
|
|
2128
|
+
content: renderPickModelReport('error', {
|
|
2129
|
+
action: `selector 约束下无法为 role "${emptyRole}" 选择模型。\n${selectorErrorDetail(models)}\n${exclusionReasonText(models, regionBlockedModels, reach.unreachable)}`,
|
|
2130
|
+
}, symbols),
|
|
1364
2131
|
display: true,
|
|
1365
2132
|
}, { triggerTurn: false });
|
|
1366
2133
|
return;
|
|
@@ -1378,14 +2145,25 @@ export default (pi: ExtensionAPI) => {
|
|
|
1378
2145
|
const changes: string[] = [];
|
|
1379
2146
|
if (newPrimary) {
|
|
1380
2147
|
const newSel = `${newPrimary.provider}/${newPrimary.id}`;
|
|
1381
|
-
changes.push(
|
|
2148
|
+
changes.push(formatChangeLine({
|
|
2149
|
+
name: '主会话',
|
|
2150
|
+
from: oldPrimary && oldPrimary !== newSel ? oldPrimary : undefined,
|
|
2151
|
+
to: newSel,
|
|
2152
|
+
...annotationOfPicked(sel.selectorResults.reviewer, newPrimary),
|
|
2153
|
+
}));
|
|
1382
2154
|
}
|
|
1383
2155
|
for (const role of OPSX_ROLES) {
|
|
1384
2156
|
const picked = sel.selectorResults[role]?.picked;
|
|
1385
2157
|
if (!picked) continue;
|
|
1386
2158
|
const newSel = `${picked.provider}/${picked.id}`;
|
|
1387
2159
|
const oldSel = oldRolePicks[role];
|
|
1388
|
-
changes.push(
|
|
2160
|
+
changes.push(formatChangeLine({
|
|
2161
|
+
name: AGENT_MAP[role],
|
|
2162
|
+
from: oldSel ?? '(none)',
|
|
2163
|
+
to: newSel,
|
|
2164
|
+
...annotationOfPicked(sel.selectorResults[role], picked),
|
|
2165
|
+
kind: roleKindLabel(sel.roleKind[role]),
|
|
2166
|
+
}));
|
|
1389
2167
|
}
|
|
1390
2168
|
|
|
1391
2169
|
// Cross-provider ambiguity hint (design Decision 8): only when a
|
|
@@ -1407,9 +2185,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
1407
2185
|
|
|
1408
2186
|
if (newPrimary) await pi.setModel(newPrimary);
|
|
1409
2187
|
selection = sel;
|
|
1410
|
-
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint);
|
|
2188
|
+
selectionHealthKey = buildSelectionHealthKey(health, reach, familyConstraint, collapsed);
|
|
1411
2189
|
applyRoleModel(sel);
|
|
1412
|
-
const content =
|
|
2190
|
+
const content = renderPickModelReport('switch', {
|
|
2191
|
+
action: '已切换 selector 约束',
|
|
2192
|
+
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
2193
|
+
detail: changes.length > 0 ? [changes.join('\n')] : undefined,
|
|
2194
|
+
footnote: hint || undefined,
|
|
2195
|
+
}, symbols);
|
|
1413
2196
|
pi.sendMessage({
|
|
1414
2197
|
customType: 'pick-model',
|
|
1415
2198
|
content,
|
|
@@ -1420,22 +2203,59 @@ export default (pi: ExtensionAPI) => {
|
|
|
1420
2203
|
|
|
1421
2204
|
// ── /pick-model choices (default) ────────────────
|
|
1422
2205
|
const sel = await getSelectionForCommand(ctx);
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
2206
|
+
const roleLines = formatRoleLines(
|
|
2207
|
+
OPSX_ROLES.map((role) => ({
|
|
2208
|
+
name: AGENT_MAP[role],
|
|
2209
|
+
kind: sel.roleKind[role],
|
|
2210
|
+
fallbackModel: sel.roleModels[role],
|
|
2211
|
+
result: sel.selectorResults[role],
|
|
2212
|
+
})),
|
|
2213
|
+
ctx.model
|
|
2214
|
+
? {
|
|
2215
|
+
model: { provider: ctx.model.provider, id: ctx.model.id },
|
|
2216
|
+
tier: scoreModelWithProvider(ctx.model.provider, ctx.model.id, ctx.model.name, undefined).tier,
|
|
2217
|
+
}
|
|
2218
|
+
: undefined,
|
|
2219
|
+
);
|
|
2220
|
+
// Full agent inventory (project + user + bundled, host-merged).
|
|
2221
|
+
// Failure downgrades to omitting the section + warn; never blocks.
|
|
2222
|
+
let agentInventory: string | undefined;
|
|
2223
|
+
try {
|
|
2224
|
+
agentInventory = formatAgentInventory((await discoverAgents(directory)).agents, symbols);
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
warn(`[omp-opsx-addon] agent inventory unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
2227
|
+
}
|
|
2228
|
+
const detail: string[] = [];
|
|
2229
|
+
if (roleLines.length > 0) detail.push(roleLines.join('\n'));
|
|
2230
|
+
if (agentInventory) detail.push(agentInventory);
|
|
2231
|
+
const concurrencySummary = ownedConcurrency?.controller.summaryLines() ?? [];
|
|
2232
|
+
// Regional-variant appendix line (china-config): resolved fresh from
|
|
2233
|
+
// the live registry with the same resolution the recompute paths use.
|
|
2234
|
+
const poolModels = ctx.modelRegistry?.getAvailable() ?? [];
|
|
2235
|
+
const choicesGetApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
2236
|
+
? (provider: string) => ctx.modelRegistry.getApiKeyForProvider(provider)
|
|
2237
|
+
: undefined;
|
|
2238
|
+
const variantLine = variantCollapseAnnotation(
|
|
2239
|
+
await resolveVariantState(poolModels, choicesGetApiKey, {
|
|
2240
|
+
unreachable: firstUnreachableProviders,
|
|
2241
|
+
availableByProvider,
|
|
2242
|
+
}),
|
|
2243
|
+
);
|
|
2244
|
+
const appendixParts: string[] = [];
|
|
2245
|
+
if (concurrencySummary.length > 0) appendixParts.push('**并发控制**', ...concurrencySummary);
|
|
2246
|
+
if (variantLine) appendixParts.push(variantLine);
|
|
2247
|
+
const content = renderPickModelReport('choices', {
|
|
2248
|
+
action: '当前选择',
|
|
2249
|
+
constraint: formatFamilyConstraint(familyConstraint) || undefined,
|
|
2250
|
+
detail,
|
|
2251
|
+
overview: formatCandidateOverview(OPSX_ROLES.map((role) => sel.selectorResults[role])),
|
|
2252
|
+
appendix: appendixParts.length > 0 ? appendixParts.join('\n') : undefined,
|
|
2253
|
+
footnote: formatFootnote(OPSX_ROLES.map((role) => sel.selectorResults[role]), sel.paygLastResort),
|
|
2254
|
+
}, symbols);
|
|
2255
|
+
pi.sendMessage({ customType: 'pick-model', content, display: true }, { triggerTurn: false });
|
|
1436
2256
|
} catch (e) {
|
|
1437
2257
|
warn(`[omp-opsx-addon] /pick-model error: ${e}`);
|
|
1438
|
-
pi.sendMessage({ customType: 'pick-model', content:
|
|
2258
|
+
pi.sendMessage({ customType: 'pick-model', content: `${resolveSymbols(pi.pi?.settings).error} Error: ${e}`, display: true }, { triggerTurn: false });
|
|
1439
2259
|
}
|
|
1440
2260
|
},
|
|
1441
2261
|
});
|
|
@@ -1461,7 +2281,9 @@ export default (pi: ExtensionAPI) => {
|
|
|
1461
2281
|
|
|
1462
2282
|
async function getSelectionForCommand(ctx: ExtensionCommandContext): Promise<SelectionContext> {
|
|
1463
2283
|
if (selection) return selection;
|
|
1464
|
-
|
|
2284
|
+
// Explicit command path: guard bypassed, no fresh classification
|
|
2285
|
+
// (design D5 — the lazy compute exists only for a cache miss).
|
|
2286
|
+
return ensureSelection(ctx, false, true);
|
|
1465
2287
|
}
|
|
1466
2288
|
|
|
1467
2289
|
// Cross-broker project-scoped pipe core: registers opsx_pipe_list /
|