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

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 {
@@ -706,6 +706,7 @@ async function loadClaudeQuery() {
706
706
  return claudeQueryPromise;
707
707
  }
708
708
  var NATIVE_MODEL_LABEL = "claude-code-native";
709
+ var NATIVE_PROVIDER_LABEL = PROVIDER;
709
710
  function failureCode(subtype) {
710
711
  switch (subtype) {
711
712
  case "error_during_execution":
@@ -1036,19 +1037,35 @@ var ClaudeCodeAgent = class {
1036
1037
  return true;
1037
1038
  }
1038
1039
  /**
1039
- * Resolve the model one query runs on, session choice first.
1040
+ * Resolve the model one query runs on, per-session selection first.
1040
1041
  *
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.
1042
+ * The layers, in precedence order:
1047
1043
  *
1044
+ * 1. **The `agent/request` waterfall.** This is the seam the dsh model
1045
+ * picker actually drives. api-proxy installs `installModelSelection` on
1046
+ * every agent's own context, which listens on `system-prompt/assemble`
1047
+ * to *snapshot* the session's selection and on `agent/request` to *apply*
1048
+ * the snapshot — two stages, so a mid-turn switch lands on a later step
1049
+ * rather than splitting the prompt from the route. Both must be
1050
+ * dispatched, and in that order: the request listener reads
1051
+ * `selection.assembled`, which only the assemble listener writes, so
1052
+ * dispatching the request waterfall alone yields nothing.
1053
+ * 2. `AgentOptions.model` — the create-time seed.
1054
+ * 3. `agentDefaultModel` — the global default.
1055
+ * 4. The deployment's pinned `config.model`.
1056
+ * 5. Nothing, leaving the CLI on its own model.
1057
+ *
1058
+ * Every layer is optional and every failure degrades to the next one: a
1059
+ * minimal profile mounts neither service, and a listener that throws must
1060
+ * cost this session its turn no more than a missing service does.
1061
+ *
1062
+ * @param signal - the step's cancellation signal, forwarded to prompt assembly.
1048
1063
  * @returns the chosen id (undefined leaves the CLI on its own default),
1049
1064
  * its provider route, and the layer that chose it.
1050
1065
  */
1051
- resolveModel() {
1066
+ async resolveModel(signal) {
1067
+ const selected = await this.selectionFromWaterfall(signal);
1068
+ if (selected !== void 0) return selected;
1052
1069
  if (this.options.model !== void 0) {
1053
1070
  return { model: this.options.model, provider: this.options.provider, source: "session" };
1054
1071
  }
@@ -1068,22 +1085,88 @@ var ClaudeCodeAgent = class {
1068
1085
  }
1069
1086
  return { model: void 0, provider: void 0, source: "native" };
1070
1087
  }
1071
- /** Model label recorded in the request header for one lifecycle. */
1072
- modelLabel() {
1073
- return this.resolveModel().model ?? NATIVE_MODEL_LABEL;
1088
+ /**
1089
+ * Ask the host what this session is routed to, through the two waterfalls
1090
+ * that carry a per-session selection.
1091
+ *
1092
+ * The seed handed to `agent/request` is the same one the in-process loop
1093
+ * seeds with — the agent's own options — so a host that installs no listener
1094
+ * gets its own answer back and this returns undefined, leaving the layers
1095
+ * below untouched. A listener that replaces it wins.
1096
+ *
1097
+ * The assemble pass is dispatched for its *side effect* on the selection
1098
+ * state; its returned prompt is discarded, because Claude Code builds its own
1099
+ * prompt and dsh's assembly never reaches the child. That makes this a real
1100
+ * (if small) cost per step: the host's prompt providers run and their output
1101
+ * is dropped. It is the price of reaching a selection whose only publisher is
1102
+ * that listener pair.
1103
+ *
1104
+ * @param signal - the step's cancellation signal.
1105
+ * @returns the selection when a listener supplied one, else undefined.
1106
+ */
1107
+ async selectionFromWaterfall(signal) {
1108
+ const phase = this.phase;
1109
+ if (phase.kind !== "running") return void 0;
1110
+ const { turn, step } = phase;
1111
+ const seed = {
1112
+ provider: this.options.provider ?? "",
1113
+ model: this.options.model ?? ""
1114
+ };
1115
+ try {
1116
+ const systemPrompt = this.loopCtx.get("systemPrompt");
1117
+ if (systemPrompt !== void 0) {
1118
+ await systemPrompt.assemble(assembleContextFor(this, signal));
1119
+ }
1120
+ const proposed = await this.dispatch.waterfall(
1121
+ "agent/request",
1122
+ { turn, step, signal },
1123
+ () => Promise.resolve(seed)
1124
+ );
1125
+ if (proposed.model === "" || proposed.provider === "") return void 0;
1126
+ if (proposed.provider === seed.provider && proposed.model === seed.model) return void 0;
1127
+ return { model: proposed.model, provider: proposed.provider, source: "selection" };
1128
+ } catch (error) {
1129
+ this.ctx.logger.warn("claude-code: per-session model selection unavailable: %s", error);
1130
+ return void 0;
1131
+ }
1074
1132
  }
1075
- /** Append the request header snapshot once per loop instance. */
1076
- assertRequestHeader() {
1077
- if (this.requestHeaderLogged) return;
1133
+ /**
1134
+ * Append the request header, and re-append it whenever the route changes.
1135
+ *
1136
+ * The provider written here is the **real** dsh route (`copilot-proxy`,
1137
+ * `amazon-bedrock`, …), not this engine's name. That is not cosmetic:
1138
+ * api-proxy re-reads this field on every read as "the model this session is
1139
+ * on", resolves it against `ctx.llm.listProviders()`, and locks the composer
1140
+ * when the name is not a registered provider — so writing the engine name
1141
+ * here made every session demand a fresh model pick after each turn. The
1142
+ * engine that ran the turn is recorded in the `*.loop-engine.json` sidecar,
1143
+ * which is where per-session engine provenance already lives.
1144
+ *
1145
+ * Re-logging on change mirrors the in-process loop: the header is the log's
1146
+ * record of what each request ran under, so a mid-session model switch has to
1147
+ * produce a new snapshot or the log misattributes every later turn.
1148
+ *
1149
+ * @param selected - the model resolved for the step about to run.
1150
+ */
1151
+ noteRequestHeader(selected) {
1078
1152
  const header = canonicalHeader({
1079
- config: { provider: PROVIDER, model: this.modelLabel() }
1153
+ config: {
1154
+ provider: selected.provider ?? NATIVE_PROVIDER_LABEL,
1155
+ model: selected.model ?? NATIVE_MODEL_LABEL
1156
+ }
1080
1157
  });
1081
1158
  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;
1159
+ if (!this.requestHeaderLogged) {
1160
+ this.session.append("request/header", {
1161
+ header,
1162
+ reason: baseline === void 0 ? "initial" : "resume"
1163
+ });
1164
+ this.requestHeaderLogged = true;
1165
+ return;
1166
+ }
1167
+ if (baseline === void 0 || !headerEquals(baseline, header)) {
1168
+ this.session.append("request/header", { header, reason: "change" });
1169
+ }
1087
1170
  }
1088
1171
  /** Run one Claude Code query for the current step and map its transcript into the session log. */
1089
1172
  async step() {
@@ -1099,7 +1182,9 @@ var ClaudeCodeAgent = class {
1099
1182
  if (prompt.length === 0) {
1100
1183
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
1101
1184
  }
1102
- this.assertRequestHeader();
1185
+ const selected = await this.resolveModel(signal);
1186
+ signal.throwIfAborted();
1187
+ this.noteRequestHeader(selected);
1103
1188
  signal.throwIfAborted();
1104
1189
  const controller = new AbortController();
1105
1190
  const cancel = () => {
@@ -1110,7 +1195,6 @@ var ClaudeCodeAgent = class {
1110
1195
  signal.addEventListener("abort", cancel, { once: true });
1111
1196
  const diagnostics = [];
1112
1197
  try {
1113
- const selected = this.resolveModel();
1114
1198
  const derived = await deriveProviderEnv(this.loopCtx, selected.provider);
1115
1199
  if (derived !== void 0) diagnostics.push(derived.diagnostic);
1116
1200
  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
  }
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-rc3",
8
8
  "repository": "github:vidgewong/dsh-agent-hub",
9
9
  "type": "module",
10
10
  "main": "lib/index.js",