@khorsheed/dsh-local-agent-dsh 0.2.0-rc.1 → 0.2.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.
@@ -4,7 +4,7 @@
4
4
  * namespace (default off); while ON, the controller registers the `dsh`
5
5
  * harness into the core registry (scoped home under the shared homes root,
6
6
  * `$DSH_HOME`), the `dsh-cli` delegation provider (sub-dsh headless runs with
7
- * the parent's resolved DeepSeek key), and the family delegation tool for it.
7
+ * the parent's selected API-key model route), and the family delegation tool for it.
8
8
  * While OFF — the default — nothing model-visible mounts, so the instance
9
9
  * keeps exactly the current behavior (official in-process subagent tools) and
10
10
  * the model never sees two overlapping delegation tools at once.
@@ -22,18 +22,19 @@
22
22
  *
23
23
  * @module @khorsheed/dsh-local-agent-dsh
24
24
  */
25
- import { join } from 'node:path';
25
+ import { dirname } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
26
27
  import z from '@deepseek-ai/schemastery';
27
- import { credentialRef } from '@deepseek-ai/dsh-credentials';
28
28
  import { settingsFace, vol } from '@khorsheed/dsh-local-agent';
29
29
  import { mountSubagentDelegationTool } from '@khorsheed/dsh-local-agent/tool-factory';
30
30
  import { CONTAINER_NODE_OPTIONS, DshCliProvider, dshCliVersion } from './dsh-cli-provider.js';
31
31
  import { DEFAULT_LIVE_IDLE_MS } from './live-driver.js';
32
32
  import { LiveDriverSwitch } from './live-switch.js';
33
33
  import { DshModelCatalog } from './model-catalog.js';
34
+ import { canInheritModelProvider, localModelLaunch } from './model-launch.js';
34
35
  import { DshModelBroker } from './model-broker.js';
35
36
  import { listDshSessions } from './records.js';
36
- import { defaultPresetRoot, DEFAULT_SUB_PROFILE_NAME, provisionDshScope, USER_PRESET_DIR } from './provision.js';
37
+ import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
37
38
  /** Stable Cordis plugin name; the bundle patch row id. */
38
39
  export const name = 'local-agent-dsh';
39
40
  /** Services required before the toggle controller can mount. */
@@ -69,9 +70,10 @@ function volatilize(field) {
69
70
  }
70
71
  export const Config = z.object({
71
72
  profileName: z.string().default(DEFAULT_SUB_PROFILE_NAME),
72
- apiKeyRef: z.string().default('DEEPSEEK_API_KEY'),
73
+ apiKeyRef: z.string(),
73
74
  cliLaunch: z.array(z.string()),
74
75
  headlessBundleDir: z.string(),
76
+ unitHeadlessBundleDir: z.string(),
75
77
  permissions: z.union([z.const('read-only'), z.const('workspace-write'), z.const('danger-full-access')]),
76
78
  model: volatilize(SETTINGS_FIELDS.model),
77
79
  live: volatilize(SETTINGS_FIELDS.live),
@@ -100,8 +102,8 @@ const DSH_TOOL_NAME = 'subagent_dsh';
100
102
  /**
101
103
  * This package's own name, passed to the family tool factory as the
102
104
  * tool-origin owner (AGENTS.md § Tool origin tagging — a shared factory never
103
- * hardcodes an owner). Duplicated from src/invariant.ts, the same pattern the
104
- * worktrees/room `./tool` entries use.
105
+ * hardcodes an owner), the same pattern the worktrees/room `./tool` entries
106
+ * use.
105
107
  */
106
108
  const PACKAGE_NAME = '@khorsheed/dsh-local-agent-dsh';
107
109
  /**
@@ -116,10 +118,6 @@ const dshSubagentTool = {
116
118
  mountSubagentDelegationTool(toolCtx, { provider: 'dsh-cli', toolName: DSH_TOOL_NAME }, PACKAGE_NAME);
117
119
  },
118
120
  };
119
- /** Resolve the sub-dsh credential or report unconfigured. */
120
- async function resolveApiKey(ctx, config) {
121
- return ctx.credentials.resolve(credentialRef(config.apiKeyRef ?? 'DEEPSEEK_API_KEY'));
122
- }
123
121
  /**
124
122
  * Mount the toggle controller: register the settings namespace, then mirror
125
123
  * its value into harness/provider/tool registrations, live.
@@ -181,6 +179,7 @@ export function apply(ctx, config) {
181
179
  const modelCatalog = new DshModelCatalog({
182
180
  llm: () => ctx.get('llm'),
183
181
  defaultModel: hostDefaultModel,
182
+ canInheritProvider: provider => canInheritModelProvider(ctx, provider),
184
183
  });
185
184
  void modelCatalog.read();
186
185
  ctx.on('llm/adapters-updated', () => { modelCatalog.invalidate(); });
@@ -207,14 +206,28 @@ export function apply(ctx, config) {
207
206
  retireRuntime: childSessionId => currentLiveSwitch?.retireRuntime(childSessionId) ?? Promise.resolve(),
208
207
  });
209
208
  /**
210
- * This deployment's preset root — where a person or a pack installed the
211
- * presets this instance offers, and the SOURCE every scope's own copy is
212
- * taken from. Derived the way the roster derives it, from the settings
213
- * service's harness home when there is one.
209
+ * This instance's own profile directory — the loader's `baseUrl`, which
210
+ * the host sets to the directory holding the profile's `cordis.yml`. Its
211
+ * direct dependencies are where an installed preset package is found, and
212
+ * the SOURCE every scope's own copy is taken from (the same package this
213
+ * instance's capability catalog measures). Undefined when the loader
214
+ * exposes no file URL, which leaves a scope able to keep the copy it has
215
+ * but unable to take a new one.
214
216
  */
215
- const instancePresetRoot = () => {
216
- const home = ctx.get?.('settings')?.home;
217
- return home === undefined ? defaultPresetRoot() : join(home, USER_PRESET_DIR);
217
+ const instanceProfileDir = () => {
218
+ const baseUrl = ctx.baseUrl;
219
+ if (typeof baseUrl !== 'string' || !baseUrl.startsWith('file:'))
220
+ return undefined;
221
+ try {
222
+ return dirname(fileURLToPath(new URL('./package.json', baseUrl)));
223
+ }
224
+ catch {
225
+ return undefined;
226
+ }
227
+ };
228
+ const profileDirOption = () => {
229
+ const dir = instanceProfileDir();
230
+ return dir === undefined ? {} : { instanceProfileDir: dir };
218
231
  };
219
232
  const harness = {
220
233
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
@@ -242,7 +255,7 @@ export function apply(ctx, config) {
242
255
  provision: (scopedHome, options) => {
243
256
  const provisioned = provisionDshScope(scopedHome, config, {
244
257
  ...(options?.preset === undefined ? {} : { preset: options.preset }),
245
- presetRoot: instancePresetRoot(),
258
+ ...(profileDirOption()),
246
259
  });
247
260
  return {
248
261
  ...(provisioned.preset === undefined ? {} : { preset: provisioned.preset }),
@@ -251,7 +264,8 @@ export function apply(ctx, config) {
251
264
  },
252
265
  isAuthenticated: async () => {
253
266
  try {
254
- return (await resolveApiKey(ctx, config)) !== undefined;
267
+ await localModelLaunch(ctx, config, resolveModel() ?? hostDefaultModel());
268
+ return true;
255
269
  }
256
270
  catch {
257
271
  return false;
@@ -318,7 +332,7 @@ export function apply(ctx, config) {
318
332
  if (!enabled)
319
333
  return;
320
334
  // Idempotent: heals a deleted or drifted sub-profile before each round.
321
- provisionDshScope(homeDir, config, { presetRoot: instancePresetRoot() });
335
+ provisionDshScope(homeDir, config, profileDirOption());
322
336
  disposers.push(ctx.localAgent.register(harness));
323
337
  // The live driver is settings-driven WITHIN this enabled generation:
324
338
  // the card's toggle (user layer over the YAML composition base) swaps
@@ -19,13 +19,16 @@
19
19
  * @module @khorsheed/dsh-local-agent-dsh/live-driver
20
20
  */
21
21
  import { StringDecoder } from 'node:string_decoder';
22
+ import { randomUUID } from 'node:crypto';
22
23
  import { BlockAssembler, createAssistantMessage } from '@deepseek-ai/dsh-llm';
24
+ import { SessionId } from '@deepseek-ai/dsh-session';
23
25
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
24
26
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush } from '@khorsheed/dsh-local-agent';
25
27
  import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION, } from '@khorsheed/dsh-local-agent-dsh-headless/wire';
26
- import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, resolveApiKey, } from './dsh-cli-provider.js';
28
+ import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, } from './dsh-cli-provider.js';
29
+ import { localModelLaunch } from './model-launch.js';
27
30
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
28
- import { mirrorDshLiveEvent, mirrorDshSession } from './session-mirror.js';
31
+ import { mirrorDshLiveEvent, mirrorDshSession, closeMirroredOpenStep, mirroredOpenStep } from './session-mirror.js';
29
32
  /** Default idle lifetime of an unused resident runtime before reclaim. */
30
33
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
31
34
  /** Default timeout for a wire request acknowledgement. */
@@ -77,6 +80,7 @@ class LiveRuntime {
77
80
  * spawned with no model flag at all. A round whose start model differs
78
81
  * retires the runtime instead of silently running the old one.
79
82
  */
83
+ modelLaunchFingerprint;
80
84
  configurationKey;
81
85
  boundModel;
82
86
  reclaimed = false;
@@ -361,7 +365,7 @@ export class DshLiveDriver {
361
365
  * The member's live runtime, spawning it (once per member at a time) when
362
366
  * absent or dead. `signal` abandons the spawn on cancellation.
363
367
  */
364
- async ensureRuntime(spec, apiKey, signal) {
368
+ async ensureRuntime(spec, launch, signal) {
365
369
  const key = spec.sessionId;
366
370
  const startModel = spec.startModel?.trim();
367
371
  const existing = this.runtimes.get(key);
@@ -370,7 +374,8 @@ export class DshLiveDriver {
370
374
  // to a different one: retire so the respawn binds the asked-for model
371
375
  // (the sub-dsh session resumes from disk — only the process is
372
376
  // replaced).
373
- if ((spec.configuration !== undefined && existing.configurationKey !== JSON.stringify(spec.configuration))
377
+ if (existing.modelLaunchFingerprint !== launch.fingerprint
378
+ || (spec.configuration !== undefined && existing.configurationKey !== JSON.stringify(spec.configuration))
374
379
  || (spec.configuration === undefined && existing.configurationKey !== undefined)
375
380
  || (startModel !== undefined && startModel !== '' && existing.boundModel !== startModel)) {
376
381
  await this.reclaim(key);
@@ -386,7 +391,7 @@ export class DshLiveDriver {
386
391
  const pending = this.ensuring.get(key);
387
392
  if (pending !== undefined)
388
393
  return pending;
389
- const spawn = this.spawnRuntime(spec, apiKey, signal)
394
+ const spawn = this.spawnRuntime(spec, launch, signal)
390
395
  .finally(() => { this.ensuring.delete(key); });
391
396
  this.ensuring.set(key, spawn);
392
397
  return spawn;
@@ -399,7 +404,7 @@ export class DshLiveDriver {
399
404
  * cancellation mid-spawn reclaims the half-started runtime and throws the
400
405
  * abort instead, without touching the breaker.
401
406
  */
402
- async spawnRuntime(spec, apiKey, signal) {
407
+ async spawnRuntime(spec, launch, signal) {
403
408
  const key = spec.sessionId;
404
409
  if (this.disposed) {
405
410
  throw new LiveChannelUnavailableError('the live driver is disposed');
@@ -434,7 +439,7 @@ export class DshLiveDriver {
434
439
  // and the DSH_* facts survive the shared scrub only here.
435
440
  env: delegationEnv({
436
441
  DSH_HOME: spec.homeDir,
437
- DEEPSEEK_API_KEY: apiKey,
442
+ ...launch.env,
438
443
  // Same tsx tsconfig carry-over as the exec path (see startDshCliRun).
439
444
  ...process.env.TSX_TSCONFIG_PATH === undefined
440
445
  ? {}
@@ -455,6 +460,7 @@ export class DshLiveDriver {
455
460
  // A blank resolution binds no model at all — record exactly what the argv
456
461
  // carries so a later start-model comparison never retires needlessly.
457
462
  runtime.boundModel = model === undefined || model === '' ? undefined : model;
463
+ runtime.modelLaunchFingerprint = launch.fingerprint;
458
464
  runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
459
465
  runtime.onDead = () => {
460
466
  // Delete only OUR registration: a crash-then-respawn can interleave so
@@ -523,9 +529,9 @@ export class DshLiveDriver {
523
529
  if (this.draining || this.disabled)
524
530
  throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
525
531
  try {
526
- provisionDshScope(spec.homeDir, this.config);
527
- const apiKey = await resolveApiKey(this.ctx, this.config);
528
- const runtime = await this.ensureRuntime(spec, apiKey, signal);
532
+ provisionDshScope(spec.homeDir, { ...this.config, inheritModelConfiguration: true });
533
+ const launch = await localModelLaunch(this.ctx, this.config, spec.configuration?.model ?? spec.startModel ?? this.config.modelFor?.(spec.sessionId));
534
+ const runtime = await this.ensureRuntime(spec, launch, signal);
529
535
  await runtime.request('session/prepare', { sessionId: spec.sessionId, resume: spec.resume !== undefined });
530
536
  signal.throwIfAborted();
531
537
  this.armIdleTimer(spec.sessionId);
@@ -674,6 +680,7 @@ export class DshLiveDriver {
674
680
  acceptStream(entry.frame);
675
681
  }
676
682
  };
683
+ let nativeFailure;
677
684
  let resolveIdle;
678
685
  const idle = new Promise((resolve) => { resolveIdle = resolve; });
679
686
  const installHandlers = (rt) => {
@@ -705,10 +712,10 @@ export class DshLiveDriver {
705
712
  };
706
713
  };
707
714
  // Provisioning is idempotent; re-running heals a drifted sub-profile.
708
- provisionDshScope(spec.homeDir, this.config);
709
- const apiKey = await resolveApiKey(this.ctx, this.config);
715
+ provisionDshScope(spec.homeDir, { ...this.config, inheritModelConfiguration: true });
716
+ const launch = await localModelLaunch(this.ctx, this.config, spec.configuration?.model ?? spec.startModel ?? this.config.modelFor?.(spec.sessionId));
710
717
  const accepted = (async () => {
711
- const rt = await this.ensureRuntime(spec, apiKey, request.signal);
718
+ const rt = await this.ensureRuntime(spec, launch, request.signal);
712
719
  runtime = rt;
713
720
  if (runAbort.signal.aborted) {
714
721
  // Cancelled while the runtime came up and no turn exists sub-side: the
@@ -751,6 +758,7 @@ export class DshLiveDriver {
751
758
  return { output: collectOutput(), stopReason: 'aborted' };
752
759
  }
753
760
  if (reason?.kind === 'error') {
761
+ nativeFailure = { message: reason.error.message, code: reason.error.code };
754
762
  throw new Error(`subagent-dsh live: the turn failed: ${reason.error.message}`);
755
763
  }
756
764
  if (reason?.kind !== 'completed') {
@@ -777,7 +785,13 @@ export class DshLiveDriver {
777
785
  }).then(async (settled) => {
778
786
  roundSettled = true;
779
787
  liveFlush.dispose();
780
- if (turnOpened && streaming !== undefined && streamPublisher !== undefined) {
788
+ // The interrupted stream's partial content folds durably only while its
789
+ // step is still OPEN: a graceful sub-dsh unwind writes its own
790
+ // interrupted assistant/message and step/end (both crossed the wire),
791
+ // and an append at the closed step would fail the released-log reader.
792
+ const openStep = turnOpened ? mirroredOpenStep(childSession) : undefined;
793
+ if (turnOpened && streaming !== undefined && streamPublisher !== undefined
794
+ && openStep !== undefined && openStep.step === streaming.step) {
781
795
  const blocks = streaming.assembler.interruptedBlocks();
782
796
  if (blocks.length > 0) {
783
797
  childSession.append('assistant/message', {
@@ -790,22 +804,6 @@ export class DshLiveDriver {
790
804
  }
791
805
  }
792
806
  streamPublisher?.dispose();
793
- // Identical turn/end bookkeeping to the exec path — but only for a turn
794
- // that actually opened (a pre-accept cancel records no turn at all).
795
- if (turnOpened) {
796
- if (settled.stopReason === 'completed') {
797
- childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
798
- }
799
- else if (settled.stopReason === 'aborted') {
800
- childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
801
- }
802
- else {
803
- childSession.append('turn/end', {
804
- turn,
805
- reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
806
- });
807
- }
808
- }
809
807
  // Settlement clears the round's handlers: a late notification drops at
810
808
  // the runtime frame instead of landing in a dead closure.
811
809
  if (runtime !== undefined) {
@@ -813,24 +811,18 @@ export class DshLiveDriver {
813
811
  runtime.onEvent = undefined;
814
812
  runtime.onIdle = undefined;
815
813
  }
816
- // A completed answer is not a durable turn until its final boundary
817
- // and any closing stream checkpoints have reached the child store.
818
814
  if (turnOpened) {
819
- persist();
820
- await persistQueue;
821
- }
822
- return settled;
823
- });
824
- // Settle reconciliation: the file-based mirror pass (the serve side flushed
825
- // before its idle notification) dedupes against the live-mirrored prefix
826
- // and catches anything the wire dropped; the final mirror progress report
827
- // is authoritative, exactly as in the exec path. Then re-arm the reaper. A
828
- // round that never opened owns no span — skip the pass entirely (it would
829
- // re-scan the previous round or, under exec fallback, fight its polling).
830
- void result.then(async () => {
831
- try {
832
- if (turnOpened) {
833
- await persistQueue;
815
+ // Settle reconciliation IN-CHAIN: the file-based mirror pass (the serve
816
+ // side flushed before its idle notification) dedupes against the
817
+ // live-mirrored prefix and catches anything the wire dropped; the
818
+ // final mirror progress report is authoritative, exactly as in the
819
+ // exec path. It runs before turn/end so every mirrored event lands
820
+ // inside the turn window — the released-log reader rejects content
821
+ // outside an open turn on every cold read — and inside the result
822
+ // chain so the round serializer (and a resume round's turn/start)
823
+ // never overtakes the boundary. A mirror fault degrades to a warn;
824
+ // the boundary still closes.
825
+ try {
834
826
  if (mirroredMessages > 0)
835
827
  persist();
836
828
  await persistQueue.catch(() => { });
@@ -855,13 +847,35 @@ export class DshLiveDriver {
855
847
  registry?.recordRoundSettled?.(childSession.id, round);
856
848
  }
857
849
  }
850
+ catch (error) {
851
+ this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
852
+ }
853
+ // The copied tail's open step (an interrupted round leaves it open —
854
+ // the reader rejects a turn/end over it) receives its closure first.
855
+ closeMirroredOpenStep(childSession);
856
+ // Identical turn/end bookkeeping to the exec path — a completed,
857
+ // interrupted, or failed round each closes its window.
858
+ if (settled.stopReason === 'completed') {
859
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
860
+ }
861
+ else if (settled.stopReason === 'aborted') {
862
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
863
+ }
864
+ else {
865
+ childSession.append('turn/end', {
866
+ turn,
867
+ reason: { kind: 'error', error: nativeFailure ?? { message: 'the live round did not complete', code: 'UNKNOWN' } },
868
+ });
869
+ }
870
+ // A completed answer is not a durable turn until its final boundary
871
+ // and any closing stream checkpoints have reached the child store.
872
+ persist();
873
+ await persistQueue;
858
874
  }
859
- catch (error) {
860
- this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
861
- }
862
- finally {
863
- this.armIdleTimer(spec.sessionId);
864
- }
875
+ // Re-arm the idle reaper for every settled round — including one that
876
+ // never opened its turn (the runtime is up and must not leak).
877
+ this.armIdleTimer(spec.sessionId);
878
+ return settled;
865
879
  });
866
880
  // Publication gate: hold start() until the turn is accepted (channel
867
881
  // errors still throw for the provider's exec fallback), but NEVER hold it
@@ -869,7 +883,11 @@ export class DshLiveDriver {
869
883
  // the result settles aborted.
870
884
  await Promise.race([accepted, abortBranch.catch(() => undefined)]);
871
885
  return subprocessRunHandle({
872
- id: childSession.id,
886
+ // Host 0.2.1: a fresh PER-EXECUTION run id — the activation manager
887
+ // rejects a run id naming a live Session (this child session is live),
888
+ // and resume rounds reuse the child session id. The family's durable
889
+ // tracking key (the child session id) rides the staged intent instead.
890
+ id: SessionId(randomUUID()),
873
891
  result,
874
892
  signal: request.signal,
875
893
  onAbort,
@@ -15,6 +15,7 @@ export declare class DshModelCatalog {
15
15
  llm: () => DshModelDirectoryFace | undefined;
16
16
  defaultModel: () => string | undefined;
17
17
  timeoutMs?: number;
18
+ canInheritProvider?: (provider: string) => boolean;
18
19
  });
19
20
  private key;
20
21
  read(): LocalAgentModelDirectory;
@@ -39,7 +39,8 @@ export class DshModelCatalog {
39
39
  if (llm === undefined || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') {
40
40
  return { entries: [], complete: false, customInput: true, unsupported: true, reason: 'The host exposes no adapter model directory' };
41
41
  }
42
- const providers = llm.listProviders();
42
+ const allProviders = llm.listProviders();
43
+ const providers = allProviders.filter(provider => this.deps.canInheritProvider?.(provider.id) ?? true);
43
44
  const groups = await Promise.allSettled(providers.map(async (provider) => Promise.all((await llm.listModels(provider.id)).map(async (model) => {
44
45
  let resolved;
45
46
  try {
@@ -62,10 +63,14 @@ export class DshModelCatalog {
62
63
  throw new Error('DSH adapters could not list models');
63
64
  const entries = groups.flatMap(group => group.status === 'fulfilled' ? group.value : []);
64
65
  const defaultModel = this.deps.defaultModel();
66
+ const omitted = allProviders.length - providers.length;
65
67
  return {
66
- entries, complete: failed === 0, customInput: true,
67
- ...defaultModel === undefined ? {} : { defaultModel },
68
- ...failed === 0 ? {} : { reason: `${failed} adapter model list(s) unavailable` },
68
+ entries, complete: failed === 0 && providers.length === allProviders.length, customInput: true,
69
+ ...defaultModel === undefined || !entries.some(entry => entry.value === defaultModel) ? {} : { defaultModel },
70
+ ...(failed === 0 && omitted === 0) ? {} : { reason: [
71
+ ...(failed === 0 ? [] : [`${failed} adapter model list(s) unavailable`]),
72
+ ...(omitted === 0 ? [] : [`${omitted} provider(s) do not support local API-key inheritance`]),
73
+ ].join('; ') },
69
74
  };
70
75
  }
71
76
  }
@@ -0,0 +1,14 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { LocalAgentDshConfig } from './index.ts';
3
+ /** Private launch environment: never persisted in a profile, transcript or argv. */
4
+ export declare const MODEL_CONFIG_ENV = "DSH_LOCAL_AGENT_MODEL_CONFIG";
5
+ /** Whether this provider has a supported local configuration transfer contract. */
6
+ export declare function canInheritModelProvider(ctx: Context, provider: string): boolean;
7
+ export interface LocalModelLaunch {
8
+ readonly env: Record<string, string>;
9
+ /** In-memory fingerprint also retires a resident process after endpoint/key changes. */
10
+ readonly fingerprint: string;
11
+ }
12
+ /** Snapshot only the selected local route and resolve only its explicitly named credential. */
13
+ export declare function localModelLaunch(ctx: Context, config: LocalAgentDshConfig, model?: string): Promise<LocalModelLaunch>;
14
+ //# sourceMappingURL=model-launch.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
3
+ /** Private launch environment: never persisted in a profile, transcript or argv. */
4
+ export const MODEL_CONFIG_ENV = 'DSH_LOCAL_AGENT_MODEL_CONFIG';
5
+ const MODEL_KEY_ENV = 'DSH_LOCAL_AGENT_MODEL_API_KEY';
6
+ function object(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
8
+ }
9
+ function settings(ctx) {
10
+ return new Map((ctx.get('settings')?.describe?.() ?? []).flatMap(row => {
11
+ const value = object(row.value);
12
+ return value === undefined ? [] : [[String(row.ns), value]];
13
+ }));
14
+ }
15
+ function piProfile(ctx, provider, values) {
16
+ const directory = ctx.get('llm')?.listConfigurableProviders?.().find(row => row.provider === provider);
17
+ // Only the official pi-ai providers dictionary has this inheritance contract.
18
+ // Other adapter families must explicitly provide a transport before being advertised.
19
+ const ns = directory?.settingsNs ?? 'llm-pi-ai';
20
+ if (ns !== 'llm-pi-ai')
21
+ return undefined;
22
+ if (directory && (directory.settingsPath.length !== 2 || directory.settingsPath[0] !== 'providers' || directory.settingsPath[1] !== provider))
23
+ return undefined;
24
+ return object(object(values.get(ns)?.providers)?.[provider]);
25
+ }
26
+ /** Whether this provider has a supported local configuration transfer contract. */
27
+ export function canInheritModelProvider(ctx, provider) {
28
+ if (provider === 'deepseek-official')
29
+ return true;
30
+ const profile = piProfile(ctx, provider, settings(ctx));
31
+ return typeof profile?.apiKeyEnv === 'string' && profile.apiKeyEnv.trim() !== '';
32
+ }
33
+ /** Snapshot only the selected local route and resolve only its explicitly named credential. */
34
+ export async function localModelLaunch(ctx, config, model) {
35
+ const provider = model?.includes('/') ? model.slice(0, model.indexOf('/')) : 'deepseek-official';
36
+ const values = settings(ctx);
37
+ let inherited, ref, keyName;
38
+ if (provider === 'deepseek-official') {
39
+ const profile = structuredClone(values.get('llm-deepseek') ?? {});
40
+ ref = config.apiKeyRef ?? (typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : 'DEEPSEEK_API_KEY');
41
+ inherited = { deepseek: { ...profile, apiKeyEnv: 'DEEPSEEK_API_KEY' } };
42
+ keyName = 'DEEPSEEK_API_KEY';
43
+ }
44
+ else {
45
+ const profile = piProfile(ctx, provider, values);
46
+ if (typeof profile?.apiKeyEnv !== 'string' || profile.apiKeyEnv.trim() === '') {
47
+ throw new Error(`subagent-dsh: model provider "${provider}" has no inheritable API-key route; configure its credential reference in the host Models settings`);
48
+ }
49
+ ref = profile.apiKeyEnv;
50
+ inherited = { piAi: { providers: { [provider]: { ...structuredClone(profile), apiKeyEnv: MODEL_KEY_ENV } } } };
51
+ keyName = MODEL_KEY_ENV;
52
+ }
53
+ const credential = await ctx.get('credentials')?.resolve(credentialRef(ref));
54
+ if (!credential?.value)
55
+ throw new Error(`subagent-dsh: credential ${ref} is unavailable for model provider "${provider}"`);
56
+ const env = { [MODEL_CONFIG_ENV]: JSON.stringify(inherited), [keyName]: credential.value };
57
+ return { env, fingerprint: createHash('sha256').update(JSON.stringify(env)).digest('hex') };
58
+ }