@sagentlab/navarch-runtime 0.1.133 → 0.1.134

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 (2) hide show
  1. package/dist/adapters/acp.cjs +183 -15
  2. package/package.json +1 -1
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.acpAdapter = void 0;
4
7
  exports.runAcpAdapter = runAcpAdapter;
8
+ exports.probeAcpSteering = probeAcpSteering;
5
9
  const node_child_process_1 = require("node:child_process");
6
10
  const node_fs_1 = require("node:fs");
11
+ const node_os_1 = __importDefault(require("node:os"));
7
12
  const node_stream_1 = require("node:stream");
8
13
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
14
  const types_cjs_1 = require("./types.cjs");
@@ -16,6 +21,21 @@ const types_cjs_1 = require("./types.cjs");
16
21
  * gets no chance to release what it is holding.
17
22
  */
18
23
  const CANCEL_GRACE_MS = 2_000;
24
+ /** How long the steering probe waits for the agent to answer `initialize`. */
25
+ const PROBE_TIMEOUT_MS = 10_000;
26
+ /**
27
+ * What an ACP agent that answered the probe's `initialize` with protocol v1
28
+ * can do: `session/cancel` and a further `session/prompt` on the same session
29
+ * are core v1 methods, so an interrupt stops the running prompt and starts the
30
+ * next one on the held connection. ACP has no input into a running prompt, so
31
+ * steer stays `none`, and a queued message still waits for the session loop's
32
+ * turn boundary.
33
+ */
34
+ const ACP_NATIVE_STEERING = Object.freeze({
35
+ steer: "none",
36
+ queue: "boundary",
37
+ interrupt: "native",
38
+ });
19
39
  /**
20
40
  * Generic Agent Client Protocol v1 adapter. DeepSeek Harness is the default
21
41
  * implementation (`dsh --profile acp`), while NAVARCH_ACP_BIN and
@@ -36,19 +56,33 @@ async function runAcpAdapter(options) {
36
56
  const rawStdout = [];
37
57
  const stderr = [];
38
58
  const protocolInput = new node_stream_1.PassThrough();
39
- const messages = [];
59
+ // Agent messages of the current prompt only: after an interrupt, the report
60
+ // is what the last prompt produced, not the text of the turn it cut short.
61
+ let messages = [];
40
62
  const thoughts = [];
41
63
  let costUsd;
42
64
  let timedOut = false;
43
65
  let killedByLeaseLoss = false;
44
66
  let sessionId;
45
67
  let stopReason;
46
- // Resolved once the in-flight prompt request settles, however it settles, so
68
+ // Settles once the in-flight prompt request settles, however it settles, so
47
69
  // cancellation can wait for the agent instead of racing the kill.
48
- let settlePrompt = () => undefined;
49
- const promptSettled = new Promise((resolve) => {
50
- settlePrompt = resolve;
51
- });
70
+ let promptSettled = Promise.resolve();
71
+ // Native interrupt (docs/navarch/live-steering.md section 5.3, row `acp`).
72
+ const live = options.liveInput;
73
+ let attached = false;
74
+ let promptRunning = false;
75
+ // This adapter sent `session/cancel` to stop the running prompt for an interrupt.
76
+ let interruptCancelSent = false;
77
+ // Interrupt inputs that wait for the running prompt to return, in the order they were sent.
78
+ const held = [];
79
+ const detach = () => {
80
+ if (!attached)
81
+ return;
82
+ attached = false;
83
+ held.length = 0;
84
+ live?.attach(null);
85
+ };
52
86
  child.stdout.on("data", (chunk) => {
53
87
  options.onActivity?.();
54
88
  rawStdout.push(chunk);
@@ -86,6 +120,9 @@ async function runAcpAdapter(options) {
86
120
  const controller = new AbortController();
87
121
  let abortProtocol = Promise.resolve();
88
122
  controller.signal.addEventListener("abort", () => {
123
+ // A timeout, lease loss or task cancel ends the session: no interrupt may
124
+ // start another prompt, and the session takes back what was not sent.
125
+ detach();
89
126
  abortProtocol = (async () => {
90
127
  if (sessionId) {
91
128
  await connection.agent.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => undefined);
@@ -110,7 +147,7 @@ async function runAcpAdapter(options) {
110
147
  onAbort();
111
148
  try {
112
149
  const context = connection.agent;
113
- await context.request(acp.methods.agent.initialize, {
150
+ const initialized = await context.request(acp.methods.agent.initialize, {
114
151
  protocolVersion: acp.PROTOCOL_VERSION,
115
152
  clientCapabilities: {},
116
153
  });
@@ -122,17 +159,68 @@ async function runAcpAdapter(options) {
122
159
  let configOptions = created.configOptions ?? [];
123
160
  configOptions = await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "model", options.model === "default" ? undefined : options.model);
124
161
  await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "reasoning_effort", options.reasoningEffort);
125
- let response;
126
- try {
127
- response = await context.request(acp.methods.agent.session.prompt, {
128
- sessionId,
129
- prompt: [{ type: "text", text: options.prompt }],
162
+ // The probe saw protocol v1; an agent that now answers another version
163
+ // takes no interrupt, which then waits for the turn boundary.
164
+ if (live && initialized.protocolVersion === acp.PROTOCOL_VERSION && !controller.signal.aborted) {
165
+ const activeSessionId = sessionId;
166
+ attached = true;
167
+ live.attach({
168
+ // ACP has no input into a running prompt.
169
+ send: () => false,
170
+ interrupt: (input) => {
171
+ if (!attached)
172
+ return false;
173
+ held.push(input);
174
+ if (promptRunning && !interruptCancelSent) {
175
+ interruptCancelSent = true;
176
+ void connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: activeSessionId })
177
+ .catch(() => undefined);
178
+ }
179
+ return true;
180
+ },
130
181
  });
131
182
  }
132
- finally {
133
- settlePrompt();
183
+ let prompt = [{ type: "text", text: options.prompt }];
184
+ // The interrupt inputs `prompt` carries, and whether the first one stopped a prompt.
185
+ let carried = null;
186
+ for (;;) {
187
+ messages = [];
188
+ promptRunning = true;
189
+ interruptCancelSent = false;
190
+ const request = context.request(acp.methods.agent.session.prompt, { sessionId, prompt });
191
+ promptSettled = request.then(() => undefined, () => undefined);
192
+ // An interrupt that was waiting when the channel attached (steering hands
193
+ // it over at once) arrived before the first prompt ran: stop that prompt.
194
+ if (held.length > 0 && !interruptCancelSent) {
195
+ interruptCancelSent = true;
196
+ void context.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => undefined);
197
+ }
198
+ if (carried) {
199
+ // The agent reads it as the next prompt on the same session. ACP has no
200
+ // read receipt, and the ack cannot wait for the whole prompt, so the
201
+ // message counts as taken once its prompt is sent. The first input owns
202
+ // the stop; the rest were sent while it was pending.
203
+ carried.inputs.forEach((input, index) => live?.consumed(input.id, true, index === 0 && carried.stoppedTurn));
204
+ carried = null;
205
+ }
206
+ let response;
207
+ try {
208
+ response = await request;
209
+ }
210
+ finally {
211
+ promptRunning = false;
212
+ }
213
+ stopReason = response.stopReason;
214
+ // A `cancelled` prompt that an interrupt asked for is not the end of the
215
+ // session: the interrupt's prompt follows. One that a timeout, lease loss
216
+ // or task cancel asked for is.
217
+ if (controller.signal.aborted || held.length === 0)
218
+ break;
219
+ const inputs = held.splice(0);
220
+ carried = { inputs, stoppedTurn: interruptCancelSent && stopReason === "cancelled" };
221
+ prompt = inputs.map((input) => ({ type: "text", text: input.text }));
134
222
  }
135
- stopReason = response.stopReason;
223
+ detach();
136
224
  await Promise.race([
137
225
  context.request(acp.methods.agent.session.close, { sessionId }).catch(() => undefined),
138
226
  new Promise((resolve) => setTimeout(resolve, options.timeoutMs > 0 ? Math.min(5_000, options.timeoutMs) : 5_000)),
@@ -143,6 +231,7 @@ async function runAcpAdapter(options) {
143
231
  stderr.push(`\n${describeError(error)}`);
144
232
  }
145
233
  finally {
234
+ detach();
146
235
  clearTimeout(timer);
147
236
  options.signal?.removeEventListener("abort", onAbort);
148
237
  await abortProtocol;
@@ -274,8 +363,87 @@ async function terminateChild(child, options, forceContainerKill) {
274
363
  function describeError(error) {
275
364
  return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
276
365
  }
366
+ /**
367
+ * Probe results by command line. A pass is kept for the life of the runtime. A
368
+ * failure is kept for a while only, because the agent may be installed or fixed
369
+ * later, and probing it again for every claim would delay each session by up
370
+ * to the probe timeout.
371
+ */
372
+ const acpProbeCache = new Map();
373
+ const PROBE_FAILURE_TTL_MS = 10 * 60_000;
374
+ /**
375
+ * Native interrupt needs an agent that speaks ACP v1, where `session/cancel`
376
+ * and a further `session/prompt` on the same session are core methods. The
377
+ * probe starts the agent, sends `initialize`, and stops it. Only the host is
378
+ * probed: the probe has no sandbox container to reach the Docker agent, so
379
+ * Docker keeps `interrupt: restart`, as does an agent that fails to start,
380
+ * answers late, or reports another protocol version.
381
+ */
382
+ async function probeAcpSteering(context) {
383
+ if (context.sandboxMode !== "host")
384
+ return null;
385
+ const key = JSON.stringify([context.bin, ...context.extraArgs]);
386
+ let probe = acpProbeCache.get(key);
387
+ if (!probe) {
388
+ probe = speaksAcpV1(context).catch(() => false);
389
+ acpProbeCache.set(key, probe);
390
+ const cached = probe;
391
+ void cached.then((passed) => {
392
+ if (passed)
393
+ return;
394
+ setTimeout(() => {
395
+ if (acpProbeCache.get(key) === cached)
396
+ acpProbeCache.delete(key);
397
+ }, PROBE_FAILURE_TTL_MS).unref();
398
+ });
399
+ }
400
+ return (await probe) ? ACP_NATIVE_STEERING : null;
401
+ }
402
+ async function speaksAcpV1(context) {
403
+ const acp = await import("@agentclientprotocol/sdk");
404
+ const child = (0, node_child_process_1.spawn)(context.bin, context.extraArgs, {
405
+ detached: process.platform !== "win32",
406
+ // No session exists yet, so an agent that writes state at startup writes it
407
+ // in the temporary directory, not in the runtime's working directory.
408
+ cwd: node_os_1.default.tmpdir(),
409
+ env: { ...process.env, ...context.env },
410
+ stdio: ["pipe", "pipe", "ignore"],
411
+ });
412
+ const exited = new Promise((resolve) => {
413
+ child.once("error", () => resolve());
414
+ child.once("close", () => resolve());
415
+ });
416
+ child.stdin.on("error", () => undefined);
417
+ const connection = acp.client({ name: "navarch-runtime" }).connect(acp.ndJsonStream(node_stream_1.Writable.toWeb(child.stdin), node_stream_1.Readable.toWeb(child.stdout)));
418
+ let timer;
419
+ try {
420
+ return await Promise.race([
421
+ connection.agent.request(acp.methods.agent.initialize, {
422
+ protocolVersion: acp.PROTOCOL_VERSION,
423
+ clientCapabilities: {},
424
+ }).then((response) => response.protocolVersion === acp.PROTOCOL_VERSION, () => false),
425
+ exited.then(() => false),
426
+ new Promise((resolve) => {
427
+ timer = setTimeout(() => resolve(false), PROBE_TIMEOUT_MS);
428
+ }),
429
+ ]);
430
+ }
431
+ finally {
432
+ clearTimeout(timer);
433
+ connection.close();
434
+ if (child.exitCode === null && child.signalCode === null) {
435
+ (0, kill_agent_cjs_1.killAgent)(child, "SIGTERM");
436
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1_000))]);
437
+ if (child.exitCode === null && child.signalCode === null)
438
+ (0, kill_agent_cjs_1.killAgent)(child);
439
+ }
440
+ }
441
+ }
277
442
  exports.acpAdapter = {
278
443
  agentType: "acp",
279
444
  run: runAcpAdapter,
445
+ // The claim's baseline record. The probe upgrades it to ACP_NATIVE_STEERING
446
+ // on a host whose agent speaks ACP v1.
280
447
  steering: types_cjs_1.TURN_RESTART_STEERING,
448
+ probeSteering: probeAcpSteering,
281
449
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.133",
3
+ "version": "0.1.134",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",