@qvac/harness 0.5.0 → 0.7.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +142 -1
  2. package/README.md +78 -27
  3. package/dist-lib/index.d.ts +15 -1
  4. package/dist-lib/index.js +175 -53
  5. package/dist-lib/lib/capabilities/preview-model-source.d.ts +0 -1
  6. package/dist-lib/lib/connect.d.ts +3 -1
  7. package/dist-lib/lib/connect.js +23 -2
  8. package/dist-lib/lib/contract-types.d.ts +14 -0
  9. package/dist-lib/lib/docx/index.d.ts +1 -1
  10. package/dist-lib/lib/docx/index.js +1 -1
  11. package/dist-lib/lib/hardware/capability-probe.js +9 -4
  12. package/dist-lib/lib/hooks/built-in/sandbox.js +0 -5
  13. package/dist-lib/lib/models/model-catalog-constants.d.ts +7 -0
  14. package/dist-lib/lib/models/model-catalog-constants.js +17 -0
  15. package/dist-lib/lib/models/model-catalog.d.ts +16 -6
  16. package/dist-lib/lib/models/model-catalog.js +21 -6
  17. package/dist-lib/lib/models/model-list.d.ts +2 -0
  18. package/dist-lib/lib/models/model-list.js +9 -4
  19. package/dist-lib/lib/models/model-pool.d.ts +6 -2
  20. package/dist-lib/lib/models/model-pool.js +67 -11
  21. package/dist-lib/lib/models/model-task.d.ts +3 -0
  22. package/dist-lib/lib/models/model-task.js +14 -0
  23. package/dist-lib/lib/models/music-catalog.d.ts +2 -0
  24. package/dist-lib/lib/models/music-catalog.js +9 -0
  25. package/dist-lib/lib/office/index.d.ts +1 -1
  26. package/dist-lib/lib/office/index.js +1 -1
  27. package/dist-lib/lib/office/python.js +1 -2
  28. package/dist-lib/lib/serve.js +12 -0
  29. package/dist-lib/lib/tools/exec/adapters/python/deadline-state.d.ts +10 -0
  30. package/dist-lib/lib/tools/exec/adapters/python/deadline-state.js +5 -0
  31. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +10 -17
  32. package/dist-lib/lib/tools/exec/adapters/python/loader.js +15 -11
  33. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -15
  34. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +1 -0
  35. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +13 -2
  36. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +3 -3
  37. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +3 -14
  38. package/dist-lib/lib/tools/exec/adapters/python/worker/active.d.ts +19 -0
  39. package/dist-lib/lib/tools/exec/adapters/python/worker/active.js +72 -0
  40. package/dist-lib/lib/tools/exec/adapters/python/worker/client.d.ts +0 -1
  41. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +8 -76
  42. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +2 -126
  43. package/dist-lib/lib/tools/exec/adapters/python/worker/interrupt-deadline.d.ts +1 -0
  44. package/dist-lib/lib/tools/exec/adapters/python/worker/interrupt-deadline.js +21 -0
  45. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.d.ts +2 -0
  46. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.js +90 -0
  47. package/dist-lib/lib/tools/exec/adapters/python/worker/{spawn-node.d.ts → spawn.d.ts} +0 -1
  48. package/dist-lib/lib/tools/exec/adapters/python/worker/{spawn-node.js → spawn.js} +0 -8
  49. package/dist-lib/lib/tools/exec/bare-runtime.d.ts +2 -0
  50. package/dist-lib/lib/tools/exec/bare-runtime.js +10 -0
  51. package/dist-lib/lib/tools/exec.d.ts +0 -2
  52. package/dist-lib/lib/tools/exec.js +6 -11
  53. package/dist-lib/schema/harness.js +27 -1
  54. package/dist-lib/spec/bind.js +6 -0
  55. package/dist-lib/spec/calls.js +2 -0
  56. package/dist-lib/spec/hrpc/hrpc.json +26 -0
  57. package/dist-lib/spec/hrpc/index.js +27 -3
  58. package/dist-lib/spec/hrpc/messages.js +72 -5
  59. package/dist-lib/spec/hyperschema/index.js +72 -5
  60. package/dist-lib/spec/hyperschema/schema.json +53 -1
  61. package/dist-lib/spec/hyperschema/types.d.ts +18 -1
  62. package/package.json +40 -20
  63. package/dist/harness.bundle +0 -42312
  64. package/dist/harness.cjs +0 -18
  65. package/dist/harness.d.ts +0 -8
  66. package/dist/prebuilds-manifest.json +0 -372
  67. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.d.ts +0 -3
  68. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.js +0 -38
package/dist-lib/index.js CHANGED
@@ -4,7 +4,7 @@ import process from 'process';
4
4
  import ReadyResource from 'ready-resource';
5
5
  import { probeGpuStats } from './lib/hardware/gpu-probe.js';
6
6
  import { probeCapabilityProfile } from './lib/hardware/capability-probe.js';
7
- import { closePythonWorker } from './lib/tools/exec/adapters/python/worker/client.js';
7
+ import { closePythonWorker } from './lib/tools/exec/adapters/python/worker/active.js';
8
8
  import { completion, imageSettingsFrom } from './lib/capabilities/completion.js';
9
9
  import { detectLanguage } from './lib/capabilities/detect-language.js';
10
10
  import { invoke } from './lib/capabilities/invoke.js';
@@ -21,10 +21,11 @@ import { buildWavBuffer } from './lib/wav-encode.js';
21
21
  import { createVoiceLane, packSamples, speakOnce, speakStreamOnce, transcribeOnce, transcribeStreaming, SUPERTONIC_SAMPLE_RATE } from './lib/capabilities/voice.js';
22
22
  import { pushQueue } from './lib/queue.js';
23
23
  import { AbortedError, raceAbort } from './lib/helpers/race-abort.js';
24
- import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, infraModelsFor, modelNameToCapabilityProfile, musicModelSet, setCustomModels } from './lib/models/model-catalog.js';
24
+ import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, curatedModelAsset, infraModelsFor, modelAddonOf, modelNameToCapabilityProfile, musicModelSet, residentSrc, setCustomModels } from './lib/models/model-catalog.js';
25
25
  import { createModelStatusCache, customModelRows, invalidateModelStatusCache, mergeCustomRows, listModels } from './lib/models/model-list.js';
26
26
  import { deleteModelFiles, summarizeDelete } from './lib/models/model-delete.js';
27
- import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, modelKey, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
27
+ import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictIdleWhere, evictModel, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, modelKey, modelOf, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
28
+ import { defaultContextSize } from './lib/models/context.js';
28
29
  import { resolveMainGpu } from './lib/models/gpu-selection.js';
29
30
  import { createIdleReaper } from './lib/models/idle-unload.js';
30
31
  import { openclCacheDir } from './lib/models/opencl-cache.js';
@@ -45,7 +46,7 @@ import { hlog } from './lib/log.js';
45
46
  import { harness } from './spec/index.js';
46
47
  import { installResolutionDetector, resolutionDetail } from './lib/resolution-detector.js';
47
48
  import { probeBinVersions } from './lib/tools/skills/bin-check.js';
48
- import { bareRuntime } from './lib/tools/exec.js';
49
+ import { bareRuntime } from './lib/tools/exec/bare-runtime.js';
49
50
  import { runCommand } from './lib/tools/exec/runner.js';
50
51
  import { createLocalRoots } from './lib/tools/fs/local.js';
51
52
  import { createRemoteRoots } from './lib/tools/fs/remote.js';
@@ -81,6 +82,7 @@ const MUSIC_EVICT_WAIT_MS = 15_000;
81
82
  const WEDGED_EVICT_WAIT_MS = 10_000;
82
83
  const MODEL_ROOM_WAIT_MS = 300_000;
83
84
  const WARM_ROOM_WAIT_MS = 2_000;
85
+ const UNLOAD_LEASE_WAIT_MS = 10_000;
84
86
  const DEFAULT_COMPLETION_MODEL_CAP = 1;
85
87
  const WEDGED_EVICT_LEASED_MS = 120_000;
86
88
  const TTS_MODEL_CONFIG = {
@@ -93,6 +95,8 @@ export class Harness extends ReadyResource {
93
95
  _tools;
94
96
  _sdk;
95
97
  _loads = createLoadTracker();
98
+ _pinned = new Set();
99
+ _replying = [];
96
100
  _modelStatus = createModelStatusCache();
97
101
  _models;
98
102
  _embedModels;
@@ -520,19 +524,18 @@ export class Harness extends ReadyResource {
520
524
  this._idle.configure(ms);
521
525
  }
522
526
  previewModelSource(context) {
523
- return previewModelSource(context, {
524
- assessFit: async ({ sha256Checksum, name, contextTokens }) => {
525
- const sdk = await this._sdk.load();
526
- const result = await sdk.assessModelFit({
527
- models: [{ model: { sha256Checksum, name }, workload: { kind: 'llm', contextTokens } }]
528
- });
529
- const model = result.models[0];
530
- return {
531
- fit: model?.verdict ?? result.verdict,
532
- fitReasons: [...(model?.reasons ?? result.reasons)]
533
- };
534
- }
527
+ return previewModelSource(context, { assessFit: (query) => this._assessFit(query) });
528
+ }
529
+ async _assessFit({ sha256Checksum, name, contextTokens }) {
530
+ const sdk = await this._sdk.load();
531
+ const result = await sdk.assessModelFit({
532
+ models: [{ model: { sha256Checksum, name }, workload: { kind: 'llm', contextTokens } }]
535
533
  });
534
+ const model = result.models[0];
535
+ return {
536
+ fit: model?.verdict ?? result.verdict,
537
+ fitReasons: [...(model?.reasons ?? result.reasons)]
538
+ };
536
539
  }
537
540
  async describe() {
538
541
  if (!this.opened)
@@ -595,7 +598,7 @@ export class Harness extends ReadyResource {
595
598
  const key = modelKey(model, loadOpts);
596
599
  const pool = this._models;
597
600
  if (!isResident(pool, key)) {
598
- await this._makeRoomForCompletion(signal);
601
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
599
602
  }
600
603
  try {
601
604
  const { modelId } = await raceAbort(pool.open(key, model, loadOpts).load(), signal);
@@ -630,7 +633,7 @@ export class Harness extends ReadyResource {
630
633
  }
631
634
  _createPool() {
632
635
  const loadSdk = this._sdk.load;
633
- return createModelPool(loadSdk, (state) => this._notifyModel(state, { languageModel: true }), {
636
+ return createModelPool(loadSdk, (state) => this._notifyModel(state), {
634
637
  deviceConfig: this._deviceConfig(),
635
638
  loads: this._loads
636
639
  });
@@ -663,11 +666,18 @@ export class Harness extends ReadyResource {
663
666
  loads: this._loads
664
667
  });
665
668
  }
666
- _notifyModel(state, { languageModel = false } = {}) {
667
- const catalogChanged = languageModel && (state.status === 'ready' || state.status === 'unloaded');
669
+ _notifyModel(state) {
670
+ const catalogChanged = state.status === 'ready' || state.status === 'unloaded';
668
671
  if (catalogChanged) {
669
672
  invalidateModelStatusCache(this._modelStatus);
670
673
  }
674
+ if (state.status === 'unloaded') {
675
+ for (const key of this._pinned) {
676
+ if (!isResident(this._models, key)) {
677
+ this._pinned.delete(key);
678
+ }
679
+ }
680
+ }
671
681
  this.emit('model', { ...state, catalogChanged, loaded: this._loadedModels() });
672
682
  }
673
683
  async _collectRetiredEmbedPool(pool) {
@@ -874,7 +884,7 @@ export class Harness extends ReadyResource {
874
884
  const pool = this._embedModels;
875
885
  const faults = this._embedFaults;
876
886
  const generation = faults.generation;
877
- const key = faults.key(model);
887
+ const key = faults.key(residentSrc(model, 'embeddings'));
878
888
  const handle = pool.open(key, model);
879
889
  let loaded = null;
880
890
  let job = null;
@@ -929,7 +939,8 @@ export class Harness extends ReadyResource {
929
939
  await this.ready();
930
940
  if (this.suspended)
931
941
  throw new Error('harness is suspended');
932
- const name = model || (await this._defaultVoiceSrc(category));
942
+ const addon = category === 'tts' ? 'tts' : 'parakeet';
943
+ const name = residentSrc(model || (await this._defaultVoiceSrc(category)), addon);
933
944
  const handle = pool.open(name, name);
934
945
  let job = null;
935
946
  let releaseAfter = null;
@@ -997,7 +1008,7 @@ export class Harness extends ReadyResource {
997
1008
  await this.ready();
998
1009
  if (this.suspended)
999
1010
  throw new Error('harness is suspended');
1000
- const name = model || (await this._defaultVoiceSrc('tts'));
1011
+ const name = residentSrc(model || (await this._defaultVoiceSrc('tts')), 'tts');
1001
1012
  const pool = this._ttsModels;
1002
1013
  const handle = pool.open(name, name);
1003
1014
  const chunks = pushQueue();
@@ -1123,29 +1134,143 @@ export class Harness extends ReadyResource {
1123
1134
  await this.ready();
1124
1135
  if (this.suspended)
1125
1136
  return;
1126
- const projectionModel = this._projectionFor(model);
1127
- const loadOpts = resolveLoadOptions({
1128
- ctxSize: agent?.ctxSize ?? undefined,
1129
- kvCacheK: agent?.kvCacheK ?? undefined,
1130
- kvCacheV: agent?.kvCacheV ?? undefined,
1131
- projectionModel
1132
- }, this._deviceConfig());
1133
- const key = modelKey(model, loadOpts);
1134
- const pool = this._models;
1135
- hlog('warm model', { model, resident: isResident(pool, key) });
1136
- if (!isResident(pool, key)) {
1137
- if (!(await this._makeRoomForCompletion(signal, WARM_ROOM_WAIT_MS))) {
1137
+ const target = this._residency(model, agent);
1138
+ const resident = isResident(target.pool, target.key);
1139
+ hlog('warm model', { model, resident });
1140
+ if (target.pool === this._models && !resident) {
1141
+ const roomed = await this._makeRoomForCompletion(model, {
1142
+ ctxSize: target.ctxSize,
1143
+ signal,
1144
+ deadlineMs: WARM_ROOM_WAIT_MS
1145
+ });
1146
+ if (!roomed) {
1138
1147
  return;
1139
1148
  }
1140
- await this._imageRuntime?.unloadIdle().catch(noop);
1149
+ await this._unloadIdleGenerators();
1150
+ }
1151
+ await this._loadAndRelease(target, signal);
1152
+ }
1153
+ loadModel(context) {
1154
+ return this._busy(() => this._loadModel(context));
1155
+ }
1156
+ async _loadModel({ model, agent, signal }) {
1157
+ if (!this.opened)
1158
+ await this.ready();
1159
+ if (this.suspended) {
1160
+ throw new Error('harness is suspended');
1161
+ }
1162
+ const target = this._residency(model, agent);
1163
+ const resident = isResident(target.pool, target.key);
1164
+ hlog('load model', { model, resident });
1165
+ if (target.pool !== this._models) {
1166
+ await this._loadAndRelease(target, signal);
1167
+ return { loaded: true };
1168
+ }
1169
+ if (!resident) {
1170
+ if (!(await this._fits(model, target.ctxSize))) {
1171
+ return { loaded: false, reason: 'memory' };
1172
+ }
1173
+ await evictIdleWhere(this._models, (key) => modelOf(key) === model && !this._replying.includes(key));
1174
+ await this._unloadIdleGenerators();
1175
+ }
1176
+ this._pinned.add(target.key);
1177
+ try {
1178
+ await this._loadAndRelease(target, signal);
1179
+ }
1180
+ catch (err) {
1181
+ this._pinned.delete(target.key);
1182
+ throw err;
1183
+ }
1184
+ return { loaded: true };
1185
+ }
1186
+ unloadModel(context) {
1187
+ return this._busy(() => this._unloadModel(context));
1188
+ }
1189
+ async _unloadModel({ model }) {
1190
+ if (!this.opened)
1191
+ await this.ready();
1192
+ const target = this._residency(model);
1193
+ const name = modelOf(target.key);
1194
+ const unloaded = await evictModel(target.pool, name, { deadlineMs: UNLOAD_LEASE_WAIT_MS });
1195
+ hlog('unload model', { model, unloaded });
1196
+ if (unloaded) {
1197
+ for (const key of this._pinned) {
1198
+ if (modelOf(key) === name) {
1199
+ this._pinned.delete(key);
1200
+ }
1201
+ }
1141
1202
  }
1203
+ return { unloaded };
1204
+ }
1205
+ async _loadAndRelease(target, signal) {
1142
1206
  try {
1143
- await raceAbort(pool.open(key, model, loadOpts).load(), signal);
1207
+ await raceAbort(target.open().load(), signal);
1144
1208
  }
1145
1209
  finally {
1146
- releaseModel(pool, key);
1210
+ releaseModel(target.pool, target.key);
1147
1211
  }
1148
1212
  }
1213
+ async _unloadIdleGenerators() {
1214
+ await this._imageRuntime?.unloadIdle().catch(noop);
1215
+ await this._musicRuntime?.unloadIdle().catch(noop);
1216
+ }
1217
+ async _fits(model, ctxSize) {
1218
+ const asset = curatedModelAsset(model, 'llm');
1219
+ if (!asset) {
1220
+ return true;
1221
+ }
1222
+ const assessed = await this._assessFit({
1223
+ sha256Checksum: asset.sha256Checksum,
1224
+ name: asset.name,
1225
+ contextTokens: ctxSize || defaultContextSize(asset)
1226
+ }).catch((err) => {
1227
+ hlog('fit estimate failed', { model, err: errorMessage(err) });
1228
+ return null;
1229
+ });
1230
+ return assessed?.fit !== 'likely-too-large';
1231
+ }
1232
+ async _yieldPins(model, ctxSize) {
1233
+ for (const key of this._pinned) {
1234
+ if (leaseCount(this._models, key) > 0 || this._replying.includes(key)) {
1235
+ continue;
1236
+ }
1237
+ if (await this._fits(model, ctxSize))
1238
+ return;
1239
+ this._pinned.delete(key);
1240
+ await evictModelWithin(this._models, key, { deadlineMs: WEDGED_EVICT_WAIT_MS });
1241
+ }
1242
+ }
1243
+ _residency(model, agent) {
1244
+ const addon = modelAddonOf(model);
1245
+ if (addon === 'llm') {
1246
+ const loadOpts = this._completionLoadOptions(model, agent);
1247
+ const key = modelKey(model, loadOpts);
1248
+ return {
1249
+ pool: this._models,
1250
+ key,
1251
+ ctxSize: loadOpts.ctxSize,
1252
+ open: () => this._models.open(key, model, loadOpts)
1253
+ };
1254
+ }
1255
+ if (addon === 'embeddings') {
1256
+ const key = this._embedFaults.key(residentSrc(model, addon));
1257
+ return { pool: this._embedModels, key, open: () => this._embedModels.open(key, model) };
1258
+ }
1259
+ if (addon === 'tts' || addon === 'parakeet') {
1260
+ const pool = addon === 'tts' ? this._ttsModels : this._transcriptionModels;
1261
+ const key = residentSrc(model, addon);
1262
+ return { pool, key, open: () => pool.open(key, key) };
1263
+ }
1264
+ throw new Error(`${model} loads for each generation and is not kept in memory`);
1265
+ }
1266
+ _completionLoadOptions(model, agent) {
1267
+ return resolveLoadOptions({
1268
+ ctxSize: agent?.ctxSize ?? undefined,
1269
+ kvCacheK: agent?.kvCacheK ?? undefined,
1270
+ kvCacheV: agent?.kvCacheV ?? undefined,
1271
+ projectionModel: this._projectionFor(model)
1272
+ }, this._deviceConfig());
1273
+ }
1149
1274
  invoke(context) {
1150
1275
  return this._busyStream(() => this._invoke(context));
1151
1276
  }
@@ -1184,27 +1309,20 @@ export class Harness extends ReadyResource {
1184
1309
  await this.ready();
1185
1310
  if (this.suspended)
1186
1311
  throw new Error('harness is suspended');
1187
- const projectionModel = this._projectionFor(model);
1188
- const loadOpts = resolveLoadOptions({
1189
- ctxSize: agent?.ctxSize ?? undefined,
1190
- kvCacheK: agent?.kvCacheK ?? undefined,
1191
- kvCacheV: agent?.kvCacheV ?? undefined,
1192
- projectionModel
1193
- }, this._deviceConfig());
1312
+ const loadOpts = this._completionLoadOptions(model, agent);
1313
+ const projectionModel = loadOpts.projectionModel;
1194
1314
  const key = modelKey(model, loadOpts);
1195
1315
  const pool = this._models;
1196
1316
  const resident = isResident(pool, key);
1197
1317
  hlog('completion start', { model, resident, projection: Boolean(projectionModel) });
1198
1318
  if (!resident) {
1199
- await this._makeRoomForCompletion(signal);
1200
- await this._imageRuntime?.unloadIdle().catch(noop);
1201
- await this._musicRuntime?.unloadIdle().catch(noop);
1319
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
1320
+ await this._unloadIdleGenerators();
1202
1321
  }
1203
1322
  const acquireRound = async () => {
1204
1323
  if (!isResident(pool, key)) {
1205
- await this._makeRoomForCompletion(signal);
1206
- await this._imageRuntime?.unloadIdle().catch(noop);
1207
- await this._musicRuntime?.unloadIdle().catch(noop);
1324
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
1325
+ await this._unloadIdleGenerators();
1208
1326
  }
1209
1327
  try {
1210
1328
  const { modelId } = await raceAbort(pool.open(key, model, loadOpts).load(), signal);
@@ -1228,6 +1346,7 @@ export class Harness extends ReadyResource {
1228
1346
  held = false;
1229
1347
  releaseModel(pool, key);
1230
1348
  };
1349
+ this._replying.push(key);
1231
1350
  try {
1232
1351
  const { modelId, ctxSize } = await raceAbort(handle.load(), signal);
1233
1352
  hlog('model ready', { model });
@@ -1287,6 +1406,7 @@ export class Harness extends ReadyResource {
1287
1406
  }
1288
1407
  finally {
1289
1408
  hlog('completion end', { model });
1409
+ this._replying.splice(this._replying.indexOf(key), 1);
1290
1410
  releaseHeld();
1291
1411
  void this._imageRuntime?.unloadIdle().catch(noop);
1292
1412
  void this._musicRuntime?.unloadIdle().catch(noop);
@@ -1315,11 +1435,12 @@ export class Harness extends ReadyResource {
1315
1435
  }
1316
1436
  };
1317
1437
  }
1318
- async _makeRoomForCompletion(signal, deadlineMs = MODEL_ROOM_WAIT_MS) {
1438
+ async _makeRoomForCompletion(model, { ctxSize, signal, deadlineMs = MODEL_ROOM_WAIT_MS } = {}) {
1319
1439
  const roomed = await awaitModelCapacity(this._models, {
1320
1440
  cap: this._completionModelCap,
1321
1441
  deadlineMs,
1322
- signal
1442
+ signal,
1443
+ evictable: (key) => !this._pinned.has(key) || modelOf(key) === model
1323
1444
  });
1324
1445
  if (!roomed) {
1325
1446
  hlog('model room wait gave up', {
@@ -1327,6 +1448,7 @@ export class Harness extends ReadyResource {
1327
1448
  loaded: loadedModels(this._models).length
1328
1449
  });
1329
1450
  }
1451
+ await this._yieldPins(model, ctxSize);
1330
1452
  return roomed;
1331
1453
  }
1332
1454
  async _evictWedgedModel(model, key) {
@@ -1,7 +1,6 @@
1
1
  import type { HarnessPreviewModelSourceResponse } from '../../spec/hyperschema/types.d.ts';
2
2
  export type ModelFitVerdict = 'likely-fits' | 'likely-too-large' | 'unknown';
3
3
  export type ModelSourceRefusal = 'invalid-source' | 'not-found' | 'private' | 'gated' | 'web-page' | 'unreachable' | 'not-gguf' | 'not-a-model' | 'no-model-files' | 'invalid-projection';
4
- export type ModelTaskType = 'llm' | 'embeddings' | 'speech-to-text' | 'text-to-speech' | 'image-generation';
5
4
  export interface ModelSourceFile {
6
5
  src: string;
7
6
  label: string;
@@ -3,7 +3,7 @@ import { type Transport } from './rpc-session.ts';
3
3
  import { type KvCacheDeletionResult } from './capabilities/kv-cache.ts';
4
4
  import type { CustomModelEntry } from './models/model-catalog.ts';
5
5
  import { type ModelSourcePreview, type PreviewModelSourceContext } from './capabilities/preview-model-source.ts';
6
- import type { CapabilityInfo, CompletionContext, CredentialEntry, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InfraModel, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SkillsOptions, SkillWriteResult, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, WarmModelContext } from './contract-types.ts';
6
+ import type { CapabilityInfo, CompletionContext, CredentialEntry, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InfraModel, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SkillsOptions, SkillWriteResult, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, LoadModelContext, LoadModelResult, UnloadModelContext, UnloadModelResult, WarmModelContext } from './contract-types.ts';
7
7
  import { type EmbedDevice } from './models/embed-config.ts';
8
8
  import type { DeleteModelSummary } from './models/model-delete.ts';
9
9
  import type { CopyBaseResult, DeleteOverlayResult, ManagedSkill, SkillFileContent } from './tools/skills/overlay-crud.ts';
@@ -46,6 +46,8 @@ export declare class RemoteHarness extends EventEmitter implements HarnessContra
46
46
  skills(agentId?: string | null, modelName?: string | null, options?: SkillsOptions): Promise<ResolvedSkillCatalogEntry[]>;
47
47
  tools(): Promise<ToolCatalogEntry[]>;
48
48
  warmModel(context: WarmModelContext): Promise<void>;
49
+ loadModel(context: LoadModelContext): Promise<LoadModelResult>;
50
+ unloadModel(context: UnloadModelContext): Promise<UnloadModelResult>;
49
51
  list(options?: ListModelsOptions): Promise<ModelInfo[]>;
50
52
  deleteModel(name: string): Promise<DeleteModelSummary>;
51
53
  clearKvCache(): Promise<KvCacheDeletionResult>;
@@ -8,6 +8,7 @@ import { unsupportedKvCacheDeletion } from './capabilities/kv-cache.js';
8
8
  import { readModelSourcePreview } from './capabilities/preview-model-source.js';
9
9
  import { hlog } from './log.js';
10
10
  import { assertEmbedDevice } from './models/embed-config.js';
11
+ import { addonTaskType } from './models/model-task.js';
11
12
  import { errorCode, errorMessage, isRecord } from './guards.js';
12
13
  import { pushQueue } from './queue.js';
13
14
  import { stageFile, sweepStaging } from './staging.js';
@@ -540,6 +541,24 @@ export class RemoteHarness extends EventEmitter {
540
541
  agent: context.agent ? knobs(context.agent) : undefined
541
542
  }), context.signal);
542
543
  }
544
+ async loadModel(context) {
545
+ const { client } = await this._ensure();
546
+ const response = await abortable(client.loadModel({
547
+ model: context.model,
548
+ agent: context.agent ? knobs(context.agent) : undefined
549
+ }), context.signal);
550
+ return {
551
+ loaded: response.loaded === true,
552
+ reason: response.reason === 'memory' ? 'memory' : undefined
553
+ };
554
+ }
555
+ async unloadModel(context) {
556
+ const { client } = await this._ensure();
557
+ const response = await client.unloadModel({
558
+ model: context.model
559
+ });
560
+ return { unloaded: response.unloaded === true };
561
+ }
543
562
  async list(options = {}) {
544
563
  const { client } = await this._ensure();
545
564
  const response = await client.listModels({
@@ -550,7 +569,8 @@ export class RemoteHarness extends EventEmitter {
550
569
  return (response.models ?? []).map(({ actualSize, label, ...row }) => ({
551
570
  ...row,
552
571
  actualSize: actualSize || undefined,
553
- label: label || undefined
572
+ label: label || undefined,
573
+ taskType: addonTaskType(row.type)
554
574
  }));
555
575
  }
556
576
  async deleteModel(name) {
@@ -606,7 +626,8 @@ export class RemoteHarness extends EventEmitter {
606
626
  source: memoryBudget.source,
607
627
  gpuClass: memoryBudget.gpuClass || undefined
608
628
  }
609
- : undefined
629
+ : undefined,
630
+ mobile: response.mobile === true
610
631
  };
611
632
  }
612
633
  close() {
@@ -113,6 +113,17 @@ export interface WarmModelContext {
113
113
  agent?: AgentKnobs;
114
114
  signal?: CompletionContext['signal'];
115
115
  }
116
+ export type LoadModelContext = WarmModelContext;
117
+ export interface LoadModelResult {
118
+ loaded: boolean;
119
+ reason?: 'memory';
120
+ }
121
+ export interface UnloadModelContext {
122
+ model: string;
123
+ }
124
+ export interface UnloadModelResult {
125
+ unloaded: boolean;
126
+ }
116
127
  export interface DownloadContext {
117
128
  model: string;
118
129
  addon?: string;
@@ -164,6 +175,7 @@ export interface CapabilityInfo {
164
175
  capabilityProfile: HarnessCapabilityProfile;
165
176
  recommendedModelName: string;
166
177
  memoryBudget?: MemoryBudget;
178
+ mobile: boolean;
167
179
  }
168
180
  export interface CredentialEntry {
169
181
  key: string;
@@ -224,6 +236,8 @@ export interface HarnessContract {
224
236
  }>;
225
237
  list?(options?: ListModelsOptions): Promise<ModelInfo[]>;
226
238
  warmModel?(context: WarmModelContext): Promise<void>;
239
+ loadModel?(context: LoadModelContext): Promise<LoadModelResult>;
240
+ unloadModel?(context: UnloadModelContext): Promise<UnloadModelResult>;
227
241
  deleteModel?(name: string): Promise<DeleteModelSummary>;
228
242
  clearKvCache?(): Promise<KvCacheDeletionResult>;
229
243
  deleteKvCache?(kvCacheKey: string): Promise<KvCacheDeletionResult>;
@@ -1,5 +1,5 @@
1
1
  import type { AbortSignal } from 'bare-abort-controller';
2
- export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/client.ts';
2
+ export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/active.ts';
3
3
  export interface DocxExtractOptions {
4
4
  readonly signal?: AbortSignal;
5
5
  }
@@ -1,5 +1,5 @@
1
1
  import { extractWithPython } from '../office/python.js';
2
- export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/client.js';
2
+ export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/active.js';
3
3
  const PACKAGES = ['python-docx==1.2.0'];
4
4
  const EXTRACT = `from docx import Document
5
5
  from docx.text.paragraph import Paragraph
@@ -1,5 +1,5 @@
1
1
  import process from 'process';
2
- import { capabilityProfileToModelName } from '../models/model-catalog.js';
2
+ import { capabilityProfileToModelName, isMobilePlatform } from '../models/model-catalog.js';
3
3
  import { probeWithAddon } from './gpu-probe-addon.js';
4
4
  import { mobileBudget, parseNvidiaSmiOutput, parseRocmSmiOutput, parseWindowsRegistryOutput, sharedRamBudget } from './parsers.js';
5
5
  const GIB = 1024 * 1024 * 1024;
@@ -85,7 +85,7 @@ async function isPixel(reads) {
85
85
  }
86
86
  async function probeMemoryBudget(reads) {
87
87
  const os = reads.platform();
88
- if (os === 'android' || os === 'ios') {
88
+ if (isMobilePlatform(os)) {
89
89
  return mobileBudget(reads.totalmem(), await isPixel(reads));
90
90
  }
91
91
  if (os === 'darwin') {
@@ -101,9 +101,14 @@ function forcedProfile() {
101
101
  return undefined;
102
102
  }
103
103
  export async function probeCapabilityProfile(reads) {
104
+ const mobile = isMobilePlatform(reads.platform());
104
105
  const forced = forcedProfile();
105
106
  if (forced) {
106
- return { capabilityProfile: forced, recommendedModelName: capabilityProfileToModelName(forced) };
107
+ return {
108
+ capabilityProfile: forced,
109
+ recommendedModelName: capabilityProfileToModelName(forced),
110
+ mobile
111
+ };
107
112
  }
108
- return recommend(await probeMemoryBudget(reads));
113
+ return { ...recommend(await probeMemoryBudget(reads)), mobile };
109
114
  }
@@ -1,4 +1,3 @@
1
- import { planVendoredInstall } from '../../tools/exec/adapters/python/wheels.js';
2
1
  import { bareBin, firstCommandToken } from '../../tools/exec/scope.js';
3
2
  const NETWORK_BINS = new Set([
4
3
  'curl',
@@ -33,10 +32,6 @@ export class SandboxHook {
33
32
  return;
34
33
  if (!ctx.sandboxMode || ctx.toolName !== 'exec')
35
34
  return;
36
- const packages = ctx.args.packages;
37
- if (Array.isArray(packages) && packages.length && !planVendoredInstall(packages.map(String))) {
38
- return { error: 'sandbox mode: installing packages reaches the network', guidance: true };
39
- }
40
35
  const bin = bareBin(firstCommandToken(String(ctx.args.command ?? '')).replace(/^.*[/\\]/, ''));
41
36
  if (bin && NETWORK_BINS.has(bin)) {
42
37
  return { error: `sandbox mode: ${bin} reaches the network`, guidance: true };
@@ -11,6 +11,13 @@ export declare const MEDPSY_4B = "medpsy-4b-q4_k_m-imat";
11
11
  export declare function isCapabilityProfile(value: string): value is CapabilityProfile;
12
12
  export declare function meetsMinCapabilityProfile(active: CapabilityProfile, required: CapabilityProfile): boolean;
13
13
  export declare function modelNameToCapabilityProfile(modelName: string): CapabilityProfile | undefined;
14
+ export interface HostCapability {
15
+ readonly capabilityProfile: string;
16
+ readonly recommendedModelName: string;
17
+ readonly mobile: boolean;
18
+ }
19
+ export declare function isMobilePlatform(platform: string): boolean;
20
+ export declare function runnableModelName(requested: string, host: HostCapability | null): string;
14
21
  export declare function capabilityProfileToModelName(profile: CapabilityProfile): string;
15
22
  export declare const AGENT_MODEL_NAMES: readonly ["Qwen3.5-2B-Q4_K_M", "Qwen3.5-4B-Q4_K_M", "Qwen3.5-9B-Q4_K_M", "Qwen3.6-27B-UD-Q4_K_XL", "Qwen3.6-35B-A3B-UD-Q4_K_M", "Qwen3.8-27B-UD-Q4_K_XL", "medpsy-1.7b-q4_k_m-imat", "medpsy-4b-q4_k_m-imat"];
16
23
  export declare function modelDisplayName(modelName: string): string;
@@ -32,6 +32,23 @@ const MODEL_NAME_TO_PROFILE = new Map([
32
32
  export function modelNameToCapabilityProfile(modelName) {
33
33
  return MODEL_NAME_TO_PROFILE.get(modelName);
34
34
  }
35
+ export function isMobilePlatform(platform) {
36
+ return platform === 'android' || platform === 'ios';
37
+ }
38
+ export function runnableModelName(requested, host) {
39
+ if (!host?.mobile || !isCapabilityProfile(host.capabilityProfile)) {
40
+ return requested;
41
+ }
42
+ const required = modelNameToCapabilityProfile(requested);
43
+ if (!required || meetsMinCapabilityProfile(host.capabilityProfile, required)) {
44
+ return requested;
45
+ }
46
+ const fallback = modelNameToCapabilityProfile(host.recommendedModelName);
47
+ if (!fallback || !meetsMinCapabilityProfile(required, fallback)) {
48
+ return requested;
49
+ }
50
+ return host.recommendedModelName;
51
+ }
35
52
  const PROFILE_TO_MODEL_NAME = new Map([
36
53
  ['tiny', QWEN_35_2B],
37
54
  ['low', QWEN_35_2B],
@@ -5,9 +5,9 @@ import type { ModelAddon } from './model-source.ts';
5
5
  export interface SdkModelAsset extends ModelRegistryEntry {
6
6
  readonly src: string;
7
7
  }
8
- import { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG, type CapabilityProfile, type ImageModelChoice, type ImageChoiceEntry } from './model-catalog-constants.ts';
9
- export { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG };
10
- export type { CapabilityProfile, ImageModelChoice, ImageChoiceEntry };
8
+ import { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG, isMobilePlatform, runnableModelName, type CapabilityProfile, type HostCapability, type ImageModelChoice, type ImageChoiceEntry } from './model-catalog-constants.ts';
9
+ export { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG, isMobilePlatform, runnableModelName };
10
+ export type { CapabilityProfile, HostCapability, ImageModelChoice, ImageChoiceEntry };
11
11
  export interface LlmCatalogEntry {
12
12
  readonly name: string;
13
13
  readonly weight: SdkModelAsset;
@@ -49,8 +49,8 @@ export interface DiffusionDownloadMetadata {
49
49
  }
50
50
  export declare function diffusionDownloadName(model: string): string | null;
51
51
  export declare function diffusionDownloadMetadata(model: string, addon: ModelAddon | undefined): DiffusionDownloadMetadata | undefined;
52
- import { DEFAULT_MUSIC_MODEL_CHOICE, type MusicModelChoice } from './music-catalog.ts';
53
- export { DEFAULT_MUSIC_MODEL_CHOICE, type MusicModelChoice };
52
+ import { DEFAULT_MUSIC_MODEL_CHOICE, isMusicModelChoice, musicModelChoiceFrom, type MusicModelChoice } from './music-catalog.ts';
53
+ export { DEFAULT_MUSIC_MODEL_CHOICE, isMusicModelChoice, musicModelChoiceFrom, type MusicModelChoice };
54
54
  export interface AudioGenCatalogEntry {
55
55
  readonly choice: MusicModelChoice;
56
56
  readonly name: string;
@@ -78,7 +78,15 @@ export interface AgentSkillModelInput {
78
78
  readonly imageModelChoice?: ImageModelChoice | null;
79
79
  readonly musicModelChoice?: MusicModelChoice | null;
80
80
  }
81
- export declare function skillModelsForAgent(agent: AgentSkillModelInput): ReadonlyArray<CatalogModel>;
81
+ export type AgentModelUse = 'chat' | 'image' | 'music';
82
+ export interface AgentModel extends CatalogModel {
83
+ readonly use: AgentModelUse;
84
+ }
85
+ export interface AgentModelInput extends AgentSkillModelInput {
86
+ readonly modelName?: string | null;
87
+ }
88
+ export declare function skillModelsForAgent(agent: AgentSkillModelInput): ReadonlyArray<AgentModel>;
89
+ export declare function modelsForAgent(agent: AgentModelInput, host: HostCapability | null): ReadonlyArray<AgentModel>;
82
90
  export declare function diffusionSizeBytes(entry: DiffusionCatalogEntry): number;
83
91
  export declare function imageModelFor(choice: string | undefined): ImageModelSet | null;
84
92
  export interface ImageClamps {
@@ -95,6 +103,8 @@ export interface CuratedListable {
95
103
  }
96
104
  export declare function curatedListables(): CuratedListable[];
97
105
  export declare function curatedModelAsset(name: string, addon: ModelRegistryEntry['addon']): SdkModelAsset | undefined;
106
+ export declare function modelAddonOf(model: string): ModelRegistryEntry['addon'];
107
+ export declare function residentSrc(model: string, addon: ModelRegistryEntry['addon']): string;
98
108
  export declare const CUSTOM_TASK_TYPES: readonly ['llm'];
99
109
  export type CustomTaskType = (typeof CUSTOM_TASK_TYPES)[number];
100
110
  export interface CustomModelEntry {