@vidge/dsh-agent-hub 0.1.0-rc2 → 0.1.0-rc4

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/lib/index.js CHANGED
@@ -61,10 +61,10 @@ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
61
61
  import { SessionPreparation } from "@deepseek-ai/dsh-session";
62
62
 
63
63
  // src/engine-claude/agent.ts
64
- import { Inbox, agentEvents } from "@deepseek-ai/dsh-agent";
64
+ import { Inbox, agentEvents, assembleContextFor } from "@deepseek-ai/dsh-agent";
65
65
  import { LlmError, createAssistantMessage, createUserMessage, errorChain } from "@deepseek-ai/dsh-llm";
66
66
  import { createScope } from "@deepseek-ai/dsh-scope";
67
- import { canonicalHeader } from "@deepseek-ai/dsh-session";
67
+ import { canonicalHeader, headerEquals } from "@deepseek-ai/dsh-session";
68
68
 
69
69
  // src/engine-claude/mapping.ts
70
70
  import {
@@ -486,15 +486,21 @@ var SHARED_ENV_KEYS = [
486
486
  ];
487
487
  function inheritedLlmCredentials(backend) {
488
488
  const creds = {};
489
- const take = (key) => {
489
+ const chosen = backend === "auto" ? BACKEND_ENV_GROUPS.find((group) => process.env[group.selector] !== void 0) : BACKEND_ENV_GROUPS.find((group) => group.id === backend);
490
+ for (const key of chosen?.keys ?? []) {
490
491
  const value = process.env[key];
491
492
  if (value !== void 0) creds[key] = value;
492
- };
493
- const chosen = backend === "auto" ? BACKEND_ENV_GROUPS.find((group) => process.env[group.selector] !== void 0) : BACKEND_ENV_GROUPS.find((group) => group.id === backend);
494
- for (const key of chosen?.keys ?? []) take(key);
495
- for (const key of SHARED_ENV_KEYS) take(key);
493
+ }
496
494
  return creds;
497
495
  }
496
+ function inheritedSharedEnv() {
497
+ const shared = {};
498
+ for (const key of SHARED_ENV_KEYS) {
499
+ const value = process.env[key];
500
+ if (value !== void 0) shared[key] = value;
501
+ }
502
+ return shared;
503
+ }
498
504
  function claudeChildEnv(spec) {
499
505
  const derived = spec.providerEnv;
500
506
  const selected = derived ?? inheritedLlmCredentials(spec.backend ?? "auto");
@@ -506,6 +512,7 @@ function claudeChildEnv(spec) {
506
512
  }
507
513
  return {
508
514
  ...env,
515
+ ...inheritedSharedEnv(),
509
516
  ...selected,
510
517
  ...spec.model === void 0 ? {} : { ANTHROPIC_MODEL: spec.model },
511
518
  ...spec.env
@@ -606,6 +613,7 @@ function readProviderProfile(ctx, provider) {
606
613
  }
607
614
  function mapRouteToEnv(provider, profile, key) {
608
615
  const headers = profile.headers ?? {};
616
+ const extra = profile.childEnv ?? {};
609
617
  if (BEDROCK_PROVIDER_IDS.has(provider)) {
610
618
  return {
611
619
  CLAUDE_CODE_USE_BEDROCK: "1",
@@ -615,13 +623,15 @@ function mapRouteToEnv(provider, profile, key) {
615
623
  // explicit and the gateway ignores it; without one the client refuses to
616
624
  // construct. A route pointing at a corporate gateway has no real region,
617
625
  // so this is a placeholder the endpoint override makes irrelevant.
618
- ..."AWS_REGION" in headers ? {} : { AWS_REGION: "us-east-1" }
626
+ ..."AWS_REGION" in headers ? {} : { AWS_REGION: "us-east-1" },
627
+ ...extra
619
628
  };
620
629
  }
621
630
  if (ANTHROPIC_PROVIDER_IDS.has(provider) || profile.api === "anthropic-messages") {
622
631
  return {
623
632
  ANTHROPIC_AUTH_TOKEN: key,
624
- ...profile.baseURL === void 0 ? {} : { ANTHROPIC_BASE_URL: profile.baseURL }
633
+ ...profile.baseURL === void 0 ? {} : { ANTHROPIC_BASE_URL: profile.baseURL },
634
+ ...extra
625
635
  };
626
636
  }
627
637
  return void 0;
@@ -644,9 +654,10 @@ async function deriveProviderEnv(ctx, provider) {
644
654
  if (key === void 0 || key === "") return void 0;
645
655
  const env = mapRouteToEnv(provider, profile, key);
646
656
  if (env === void 0) return void 0;
657
+ const extra = Object.keys(profile.childEnv ?? {});
647
658
  return {
648
659
  env,
649
- diagnostic: `claude-code: routing to dsh provider "${provider}"${profile.baseURL === void 0 ? "" : ` at ${profile.baseURL}`} (credential ${ref})`
660
+ diagnostic: `claude-code: routing to dsh provider "${provider}"${profile.baseURL === void 0 ? "" : ` at ${profile.baseURL}`} (credential ${ref})${extra.length === 0 ? "" : `; route env ${extra.join(", ")}`}`
650
661
  };
651
662
  }
652
663
 
@@ -706,6 +717,7 @@ async function loadClaudeQuery() {
706
717
  return claudeQueryPromise;
707
718
  }
708
719
  var NATIVE_MODEL_LABEL = "claude-code-native";
720
+ var NATIVE_PROVIDER_LABEL = PROVIDER;
709
721
  function failureCode(subtype) {
710
722
  switch (subtype) {
711
723
  case "error_during_execution":
@@ -1036,19 +1048,35 @@ var ClaudeCodeAgent = class {
1036
1048
  return true;
1037
1049
  }
1038
1050
  /**
1039
- * Resolve the model one query runs on, session choice first.
1051
+ * Resolve the model one query runs on, per-session selection first.
1052
+ *
1053
+ * The layers, in precedence order:
1054
+ *
1055
+ * 1. **The `agent/request` waterfall.** This is the seam the dsh model
1056
+ * picker actually drives. api-proxy installs `installModelSelection` on
1057
+ * every agent's own context, which listens on `system-prompt/assemble`
1058
+ * to *snapshot* the session's selection and on `agent/request` to *apply*
1059
+ * the snapshot — two stages, so a mid-turn switch lands on a later step
1060
+ * rather than splitting the prompt from the route. Both must be
1061
+ * dispatched, and in that order: the request listener reads
1062
+ * `selection.assembled`, which only the assemble listener writes, so
1063
+ * dispatching the request waterfall alone yields nothing.
1064
+ * 2. `AgentOptions.model` — the create-time seed.
1065
+ * 3. `agentDefaultModel` — the global default.
1066
+ * 4. The deployment's pinned `config.model`.
1067
+ * 5. Nothing, leaving the CLI on its own model.
1040
1068
  *
1041
- * The web surface sets `AgentOptions.model` when a session picks a model, and
1042
- * `agentDefaultModel` holds the global default; reading both is what makes
1043
- * the dsh model picker mean something for this engine. The service is
1044
- * optional — a minimal profile may not mount it — so it is resolved through
1045
- * `ctx.get` rather than `inject`, and a faulting provider degrades to the
1046
- * next layer instead of failing the turn.
1069
+ * Every layer is optional and every failure degrades to the next one: a
1070
+ * minimal profile mounts neither service, and a listener that throws must
1071
+ * cost this session its turn no more than a missing service does.
1047
1072
  *
1073
+ * @param signal - the step's cancellation signal, forwarded to prompt assembly.
1048
1074
  * @returns the chosen id (undefined leaves the CLI on its own default),
1049
1075
  * its provider route, and the layer that chose it.
1050
1076
  */
1051
- resolveModel() {
1077
+ async resolveModel(signal) {
1078
+ const selected = await this.selectionFromWaterfall(signal);
1079
+ if (selected !== void 0) return selected;
1052
1080
  if (this.options.model !== void 0) {
1053
1081
  return { model: this.options.model, provider: this.options.provider, source: "session" };
1054
1082
  }
@@ -1068,22 +1096,88 @@ var ClaudeCodeAgent = class {
1068
1096
  }
1069
1097
  return { model: void 0, provider: void 0, source: "native" };
1070
1098
  }
1071
- /** Model label recorded in the request header for one lifecycle. */
1072
- modelLabel() {
1073
- return this.resolveModel().model ?? NATIVE_MODEL_LABEL;
1099
+ /**
1100
+ * Ask the host what this session is routed to, through the two waterfalls
1101
+ * that carry a per-session selection.
1102
+ *
1103
+ * The seed handed to `agent/request` is the same one the in-process loop
1104
+ * seeds with — the agent's own options — so a host that installs no listener
1105
+ * gets its own answer back and this returns undefined, leaving the layers
1106
+ * below untouched. A listener that replaces it wins.
1107
+ *
1108
+ * The assemble pass is dispatched for its *side effect* on the selection
1109
+ * state; its returned prompt is discarded, because Claude Code builds its own
1110
+ * prompt and dsh's assembly never reaches the child. That makes this a real
1111
+ * (if small) cost per step: the host's prompt providers run and their output
1112
+ * is dropped. It is the price of reaching a selection whose only publisher is
1113
+ * that listener pair.
1114
+ *
1115
+ * @param signal - the step's cancellation signal.
1116
+ * @returns the selection when a listener supplied one, else undefined.
1117
+ */
1118
+ async selectionFromWaterfall(signal) {
1119
+ const phase = this.phase;
1120
+ if (phase.kind !== "running") return void 0;
1121
+ const { turn, step } = phase;
1122
+ const seed = {
1123
+ provider: this.options.provider ?? "",
1124
+ model: this.options.model ?? ""
1125
+ };
1126
+ try {
1127
+ const systemPrompt = this.loopCtx.get("systemPrompt");
1128
+ if (systemPrompt !== void 0) {
1129
+ await systemPrompt.assemble(assembleContextFor(this, signal));
1130
+ }
1131
+ const proposed = await this.dispatch.waterfall(
1132
+ "agent/request",
1133
+ { turn, step, signal },
1134
+ () => Promise.resolve(seed)
1135
+ );
1136
+ if (proposed.model === "" || proposed.provider === "") return void 0;
1137
+ if (proposed.provider === seed.provider && proposed.model === seed.model) return void 0;
1138
+ return { model: proposed.model, provider: proposed.provider, source: "selection" };
1139
+ } catch (error) {
1140
+ this.ctx.logger.warn("claude-code: per-session model selection unavailable: %s", error);
1141
+ return void 0;
1142
+ }
1074
1143
  }
1075
- /** Append the request header snapshot once per loop instance. */
1076
- assertRequestHeader() {
1077
- if (this.requestHeaderLogged) return;
1144
+ /**
1145
+ * Append the request header, and re-append it whenever the route changes.
1146
+ *
1147
+ * The provider written here is the **real** dsh route (`copilot-proxy`,
1148
+ * `amazon-bedrock`, …), not this engine's name. That is not cosmetic:
1149
+ * api-proxy re-reads this field on every read as "the model this session is
1150
+ * on", resolves it against `ctx.llm.listProviders()`, and locks the composer
1151
+ * when the name is not a registered provider — so writing the engine name
1152
+ * here made every session demand a fresh model pick after each turn. The
1153
+ * engine that ran the turn is recorded in the `*.loop-engine.json` sidecar,
1154
+ * which is where per-session engine provenance already lives.
1155
+ *
1156
+ * Re-logging on change mirrors the in-process loop: the header is the log's
1157
+ * record of what each request ran under, so a mid-session model switch has to
1158
+ * produce a new snapshot or the log misattributes every later turn.
1159
+ *
1160
+ * @param selected - the model resolved for the step about to run.
1161
+ */
1162
+ noteRequestHeader(selected) {
1078
1163
  const header = canonicalHeader({
1079
- config: { provider: PROVIDER, model: this.modelLabel() }
1164
+ config: {
1165
+ provider: selected.provider ?? NATIVE_PROVIDER_LABEL,
1166
+ model: selected.model ?? NATIVE_MODEL_LABEL
1167
+ }
1080
1168
  });
1081
1169
  const baseline = this.session.requestHeader();
1082
- this.session.append("request/header", {
1083
- header,
1084
- reason: baseline === void 0 ? "initial" : "resume"
1085
- });
1086
- this.requestHeaderLogged = true;
1170
+ if (!this.requestHeaderLogged) {
1171
+ this.session.append("request/header", {
1172
+ header,
1173
+ reason: baseline === void 0 ? "initial" : "resume"
1174
+ });
1175
+ this.requestHeaderLogged = true;
1176
+ return;
1177
+ }
1178
+ if (baseline === void 0 || !headerEquals(baseline, header)) {
1179
+ this.session.append("request/header", { header, reason: "change" });
1180
+ }
1087
1181
  }
1088
1182
  /** Run one Claude Code query for the current step and map its transcript into the session log. */
1089
1183
  async step() {
@@ -1099,7 +1193,9 @@ var ClaudeCodeAgent = class {
1099
1193
  if (prompt.length === 0) {
1100
1194
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
1101
1195
  }
1102
- this.assertRequestHeader();
1196
+ const selected = await this.resolveModel(signal);
1197
+ signal.throwIfAborted();
1198
+ this.noteRequestHeader(selected);
1103
1199
  signal.throwIfAborted();
1104
1200
  const controller = new AbortController();
1105
1201
  const cancel = () => {
@@ -1110,7 +1206,6 @@ var ClaudeCodeAgent = class {
1110
1206
  signal.addEventListener("abort", cancel, { once: true });
1111
1207
  const diagnostics = [];
1112
1208
  try {
1113
- const selected = this.resolveModel();
1114
1209
  const derived = await deriveProviderEnv(this.loopCtx, selected.provider);
1115
1210
  if (derived !== void 0) diagnostics.push(derived.diagnostic);
1116
1211
  const options = claudeQueryOptions({
@@ -93,23 +93,72 @@ export declare class ClaudeCodeAgent implements Agent {
93
93
  /** Open one turn before claiming its first proposed step. */
94
94
  private turn;
95
95
  /**
96
- * Resolve the model one query runs on, session choice first.
96
+ * Resolve the model one query runs on, per-session selection first.
97
97
  *
98
- * The web surface sets `AgentOptions.model` when a session picks a model, and
99
- * `agentDefaultModel` holds the global default; reading both is what makes
100
- * the dsh model picker mean something for this engine. The service is
101
- * optional — a minimal profile may not mount it — so it is resolved through
102
- * `ctx.get` rather than `inject`, and a faulting provider degrades to the
103
- * next layer instead of failing the turn.
98
+ * The layers, in precedence order:
104
99
  *
100
+ * 1. **The `agent/request` waterfall.** This is the seam the dsh model
101
+ * picker actually drives. api-proxy installs `installModelSelection` on
102
+ * every agent's own context, which listens on `system-prompt/assemble`
103
+ * to *snapshot* the session's selection and on `agent/request` to *apply*
104
+ * the snapshot — two stages, so a mid-turn switch lands on a later step
105
+ * rather than splitting the prompt from the route. Both must be
106
+ * dispatched, and in that order: the request listener reads
107
+ * `selection.assembled`, which only the assemble listener writes, so
108
+ * dispatching the request waterfall alone yields nothing.
109
+ * 2. `AgentOptions.model` — the create-time seed.
110
+ * 3. `agentDefaultModel` — the global default.
111
+ * 4. The deployment's pinned `config.model`.
112
+ * 5. Nothing, leaving the CLI on its own model.
113
+ *
114
+ * Every layer is optional and every failure degrades to the next one: a
115
+ * minimal profile mounts neither service, and a listener that throws must
116
+ * cost this session its turn no more than a missing service does.
117
+ *
118
+ * @param signal - the step's cancellation signal, forwarded to prompt assembly.
105
119
  * @returns the chosen id (undefined leaves the CLI on its own default),
106
120
  * its provider route, and the layer that chose it.
107
121
  */
108
122
  private resolveModel;
109
- /** Model label recorded in the request header for one lifecycle. */
110
- private modelLabel;
111
- /** Append the request header snapshot once per loop instance. */
112
- private assertRequestHeader;
123
+ /**
124
+ * Ask the host what this session is routed to, through the two waterfalls
125
+ * that carry a per-session selection.
126
+ *
127
+ * The seed handed to `agent/request` is the same one the in-process loop
128
+ * seeds with — the agent's own options — so a host that installs no listener
129
+ * gets its own answer back and this returns undefined, leaving the layers
130
+ * below untouched. A listener that replaces it wins.
131
+ *
132
+ * The assemble pass is dispatched for its *side effect* on the selection
133
+ * state; its returned prompt is discarded, because Claude Code builds its own
134
+ * prompt and dsh's assembly never reaches the child. That makes this a real
135
+ * (if small) cost per step: the host's prompt providers run and their output
136
+ * is dropped. It is the price of reaching a selection whose only publisher is
137
+ * that listener pair.
138
+ *
139
+ * @param signal - the step's cancellation signal.
140
+ * @returns the selection when a listener supplied one, else undefined.
141
+ */
142
+ private selectionFromWaterfall;
143
+ /**
144
+ * Append the request header, and re-append it whenever the route changes.
145
+ *
146
+ * The provider written here is the **real** dsh route (`copilot-proxy`,
147
+ * `amazon-bedrock`, …), not this engine's name. That is not cosmetic:
148
+ * api-proxy re-reads this field on every read as "the model this session is
149
+ * on", resolves it against `ctx.llm.listProviders()`, and locks the composer
150
+ * when the name is not a registered provider — so writing the engine name
151
+ * here made every session demand a fresh model pick after each turn. The
152
+ * engine that ran the turn is recorded in the `*.loop-engine.json` sidecar,
153
+ * which is where per-session engine provenance already lives.
154
+ *
155
+ * Re-logging on change mirrors the in-process loop: the header is the log's
156
+ * record of what each request ran under, so a mid-session model switch has to
157
+ * produce a new snapshot or the log misattributes every later turn.
158
+ *
159
+ * @param selected - the model resolved for the step about to run.
160
+ */
161
+ private noteRequestHeader;
113
162
  /** Run one Claude Code query for the current step and map its transcript into the session log. */
114
163
  private step;
115
164
  }
@@ -13,7 +13,9 @@
13
13
  *
14
14
  * Only the route's *transport* is derived. Which model runs is settled earlier
15
15
  * by the agent's own resolution (session choice, then `agentDefaultModel`), and
16
- * is passed in rather than re-read here.
16
+ * is passed in rather than re-read here. A route may also carry a `childEnv`
17
+ * map, which is not derived at all but forwarded verbatim — the escape hatch
18
+ * for what a protocol mapping cannot express.
17
19
  *
18
20
  * Everything is resolved per call. `credentials.resolve` is contractually a
19
21
  * per-call read — the store layers process env over `~/.dsh/.credentials.yaml`
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.0-rc2",
7
+ "version": "0.1.0-rc4",
8
8
  "repository": "github:vidgewong/dsh-agent-hub",
9
9
  "type": "module",
10
10
  "main": "lib/index.js",