@vedmalex/ai-connect 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -215,6 +215,27 @@ const result = await client.generate({
215
215
  });
216
216
  ```
217
217
 
218
+ #### ACP model selection + headless harness-noise suppression
219
+
220
+ For headless / batch prompts (e.g. per-document extraction) the ACP transport drives an interactive coding agent. Two behaviours make that robust by default:
221
+
222
+ - **Model via the protocol.** The route's model (`routeHints.model ?? account model`) is selected through a `session/set_model` call after `session/new` — you do **not** need to inject an `ANTHROPIC_MODEL` env var, and there is no env-driven "model switched" announcement leaking into the output. The call is sent only when the agent advertises a model catalog, the requested model is in its `availableModels`, and it differs from the current model; a model the agent does not advertise is surfaced as a `warning` (it is not silently replaced by the agent default).
223
+ - **Harness-noise suppression + guard.** Known interactive-harness marker lines (a model-switch announcement, a `Готов к работе` / `Жду …` idle greeting, `<local-command-caveat>` commentary) are filtered out of the answer text on both the `generate` and the streaming (`delta`) paths. If a turn yields **only** such harness chatter and no task output, it is surfaced as `temporary_unavailable` so the consumer can retry / fall back rather than receiving the greeting as a successful generation.
224
+
225
+ All three are on by default and can be toggled via `acp` client options:
226
+
227
+ ```ts
228
+ const client = createLocalClient(config, {
229
+ acp: {
230
+ selectModel: true, // session/set_model from the route model (default true)
231
+ suppressHarnessNoise: true, // filter harness marker lines from text + deltas (default true)
232
+ failOnHarnessOnlyTurn: true, // harness-only turn → temporary_unavailable (default true)
233
+ },
234
+ });
235
+ ```
236
+
237
+ > Limitation: the harness-noise filter / guard recognises a curated, locale-specific marker set (Claude harness, RU greetings). A reworded or other-locale greeting that still carries non-marker text is not classified as harness-only. The model-switch root cause is removed independently by `selectModel`.
238
+
218
239
  Dedicated provider-specific ACP examples:
219
240
 
220
241
  - [examples/acp-claude.ts](/Users/vedmalex/work/ai-connect/examples/acp-claude.ts)
package/dist/bun/index.js CHANGED
@@ -6797,6 +6797,20 @@ var TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
6797
6797
  function isRecord2(value) {
6798
6798
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6799
6799
  }
6800
+ var ACP_HARNESS_NOISE_PATTERNS = [
6801
+ /^модель переключена на .+?\.?(?:\s*готов к работе[.!…]*)?$/iu,
6802
+ /^готов к работе[.!…]*$/iu,
6803
+ /^жду (?:вашего вопроса|вопроса или задачи|вашей задачи|вашего ответа|явного запроса)[^]*$/iu,
6804
+ /^это сообщение помечено как локальная команда[^]*$/iu,
6805
+ /^<local-command-(?:caveat|stdout|name|message|args)[^>]*>[^]*$/iu
6806
+ ];
6807
+ function isHarnessNoiseLine(line) {
6808
+ const trimmed = line.trim();
6809
+ if (trimmed.length === 0) {
6810
+ return false;
6811
+ }
6812
+ return ACP_HARNESS_NOISE_PATTERNS.some((pattern) => pattern.test(trimmed));
6813
+ }
6800
6814
  function splitCommandLine(commandLine) {
6801
6815
  const matches = commandLine.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
6802
6816
  const parts = matches.map((part) => {
@@ -7633,6 +7647,7 @@ function buildAcpLifecycle(route, authRequest, mode) {
7633
7647
  }
7634
7648
  steps.push("session/new");
7635
7649
  if (mode === "prompt") {
7650
+ steps.push("session/set_model");
7636
7651
  steps.push("session/prompt");
7637
7652
  }
7638
7653
  return { steps, keys };
@@ -7720,6 +7735,9 @@ var AcpConnection = class {
7720
7735
  env;
7721
7736
  timeoutMs;
7722
7737
  permissionMode;
7738
+ selectModel;
7739
+ suppressHarnessNoise;
7740
+ failOnHarnessOnlyTurn;
7723
7741
  command;
7724
7742
  args;
7725
7743
  child;
@@ -7741,6 +7759,9 @@ var AcpConnection = class {
7741
7759
  this.env = options.env;
7742
7760
  this.timeoutMs = options.timeoutMs;
7743
7761
  this.permissionMode = options.permissionMode;
7762
+ this.selectModel = options.selectModel;
7763
+ this.suppressHarnessNoise = options.suppressHarnessNoise;
7764
+ this.failOnHarnessOnlyTurn = options.failOnHarnessOnlyTurn;
7744
7765
  const resolved = splitCommandLine(options.commandLine);
7745
7766
  this.command = resolved.command;
7746
7767
  this.args = resolved.args;
@@ -7773,8 +7794,18 @@ var AcpConnection = class {
7773
7794
  usage: void 0,
7774
7795
  attachments: [],
7775
7796
  warnings: [],
7797
+ suppressNoise: this.suppressHarnessNoise,
7798
+ lineBuffer: "",
7799
+ harnessMarkers: [],
7776
7800
  ...onDelta ? { onDelta } : {}
7777
7801
  };
7802
+ await this.selectSessionModel(
7803
+ sessionId,
7804
+ context.route,
7805
+ session,
7806
+ transport.phases ?? [],
7807
+ this.activePrompt.warnings
7808
+ );
7778
7809
  let stopReason;
7779
7810
  const abortSignal = context.abort.signal;
7780
7811
  let abortListener;
@@ -7817,13 +7848,27 @@ var AcpConnection = class {
7817
7848
  }
7818
7849
  this.bumpIdleTimer();
7819
7850
  }
7851
+ this.flushHarnessLineBuffer();
7820
7852
  const stderrSlice = this.stderr.slice(stderrOffset).trim();
7821
7853
  const warnings = [...this.activePrompt.warnings];
7854
+ if (this.activePrompt.harnessMarkers.length > 0) {
7855
+ warnings.push(
7856
+ `ACP: filtered ${this.activePrompt.harnessMarkers.length} interactive-harness line(s) from the response.`
7857
+ );
7858
+ }
7822
7859
  if (stderrSlice.length > 0) {
7823
7860
  warnings.push(stderrSlice);
7824
7861
  }
7862
+ const text = this.activePrompt.text.trim();
7863
+ if (this.failOnHarnessOnlyTurn && this.activePrompt.harnessMarkers.length > 0 && text.length === 0) {
7864
+ this.activePrompt = void 0;
7865
+ throw new AiConnectError(
7866
+ "temporary_unavailable",
7867
+ "ACP turn returned only interactive-harness output (no task output)."
7868
+ );
7869
+ }
7825
7870
  const result = {
7826
- text: this.activePrompt.text.trim(),
7871
+ text,
7827
7872
  attachments: [...this.activePrompt.attachments],
7828
7873
  warnings,
7829
7874
  ...this.activePrompt.usage ? { usage: this.activePrompt.usage } : {},
@@ -8130,6 +8175,108 @@ var AcpConnection = class {
8130
8175
  }
8131
8176
  });
8132
8177
  }
8178
+ /**
8179
+ * Select the route's model through the ACP protocol (TASK-033 / UR-002).
8180
+ * Best-effort matrix: skip when disabled / no model / no advertised catalog /
8181
+ * already current; warn (not silently default) when the requested model is not
8182
+ * in the agent's `availableModels`; otherwise `session/set_model` and surface
8183
+ * any error as a warning. Never throws — a failed protocol selection must not
8184
+ * fail the generation.
8185
+ */
8186
+ async selectSessionModel(sessionId, route, sessionResult, phases, warnings) {
8187
+ if (!this.selectModel) {
8188
+ return;
8189
+ }
8190
+ const desiredModel = route.model?.trim();
8191
+ if (!desiredModel) {
8192
+ return;
8193
+ }
8194
+ const catalog = modelCatalogFromSession(sessionResult);
8195
+ if (!catalog) {
8196
+ return;
8197
+ }
8198
+ if (catalog.currentModelId === desiredModel) {
8199
+ return;
8200
+ }
8201
+ const available = catalog.availableModels ?? [];
8202
+ if (available.length > 0 && !available.some((entry) => entry.modelId === desiredModel)) {
8203
+ warnings.push(
8204
+ `ACP agent's available models [${available.map((entry) => entry.modelId).join(", ")}] do not include route model "${desiredModel}"; model not switched via protocol.`
8205
+ );
8206
+ return;
8207
+ }
8208
+ try {
8209
+ await measurePhase(
8210
+ phases,
8211
+ "session/set_model",
8212
+ async () => this.request("session/set_model", {
8213
+ sessionId,
8214
+ modelId: desiredModel
8215
+ })
8216
+ );
8217
+ } catch (error) {
8218
+ const message = error instanceof Error ? error.message : isRecord2(error) && typeof error.message === "string" ? error.message : String(error);
8219
+ warnings.push(
8220
+ `ACP session/set_model("${desiredModel}") failed: ${message}`
8221
+ );
8222
+ }
8223
+ }
8224
+ /**
8225
+ * Append `agent_message_chunk` text (TASK-033 / UR-003). With suppression off
8226
+ * (legacy) the chunk streams + accumulates raw. With suppression on, the chunk
8227
+ * is line-buffered: each COMPLETE line is classified and either dropped (a
8228
+ * harness marker — no delta, no accumulation) or emitted; the trailing partial
8229
+ * line waits in `lineBuffer` for the next chunk or the end-of-turn flush.
8230
+ */
8231
+ appendAgentText(chunk) {
8232
+ const state = this.activePrompt;
8233
+ if (!state) {
8234
+ return;
8235
+ }
8236
+ if (!state.suppressNoise) {
8237
+ state.onDelta?.(chunk);
8238
+ state.text += chunk;
8239
+ return;
8240
+ }
8241
+ state.lineBuffer += chunk;
8242
+ let newlineIndex = state.lineBuffer.indexOf("\n");
8243
+ while (newlineIndex !== -1) {
8244
+ const line = state.lineBuffer.slice(0, newlineIndex + 1);
8245
+ state.lineBuffer = state.lineBuffer.slice(newlineIndex + 1);
8246
+ this.emitAgentLine(line);
8247
+ newlineIndex = state.lineBuffer.indexOf("\n");
8248
+ }
8249
+ }
8250
+ /** Flush any trailing partial line through the harness-noise classifier. */
8251
+ flushHarnessLineBuffer() {
8252
+ const state = this.activePrompt;
8253
+ if (!state || !state.suppressNoise) {
8254
+ return;
8255
+ }
8256
+ const remaining = state.lineBuffer;
8257
+ state.lineBuffer = "";
8258
+ if (remaining.length > 0) {
8259
+ this.emitAgentLine(remaining);
8260
+ }
8261
+ }
8262
+ /**
8263
+ * Emit one classified line: a curated harness marker is recorded + dropped
8264
+ * (no delta, no accumulation); any other line streams + accumulates. `line`
8265
+ * may carry a trailing newline — classification runs on the content.
8266
+ */
8267
+ emitAgentLine(line) {
8268
+ const state = this.activePrompt;
8269
+ if (!state) {
8270
+ return;
8271
+ }
8272
+ const content = line.endsWith("\n") ? line.slice(0, -1) : line;
8273
+ if (isHarnessNoiseLine(content)) {
8274
+ state.harnessMarkers.push(content.trim());
8275
+ return;
8276
+ }
8277
+ state.onDelta?.(line);
8278
+ state.text += line;
8279
+ }
8133
8280
  handleNotification(message) {
8134
8281
  if (message.method !== "session/update" || !this.activePrompt) {
8135
8282
  return;
@@ -8144,8 +8291,7 @@ var AcpConnection = class {
8144
8291
  }
8145
8292
  const chunk = extractTextChunk(update);
8146
8293
  if (update.sessionUpdate === "agent_message_chunk" && chunk) {
8147
- this.activePrompt.onDelta?.(chunk);
8148
- this.activePrompt.text += chunk;
8294
+ this.appendAgentText(chunk);
8149
8295
  }
8150
8296
  if (update.sessionUpdate === "agent_thought_chunk" && chunk) {
8151
8297
  this.activePrompt.warnings.push(chunk);
@@ -8332,7 +8478,10 @@ function createAcpTransportManager(options) {
8332
8478
  cwd: runtime.cwd,
8333
8479
  env: runtime.env,
8334
8480
  timeoutMs: promptTimeout(options),
8335
- permissionMode: options?.permissionMode ?? "deny-all"
8481
+ permissionMode: options?.permissionMode ?? "deny-all",
8482
+ selectModel: options?.selectModel ?? true,
8483
+ suppressHarnessNoise: options?.suppressHarnessNoise ?? true,
8484
+ failOnHarnessOnlyTurn: options?.failOnHarnessOnlyTurn ?? true
8336
8485
  };
8337
8486
  }
8338
8487
  function getPool(key) {