toolnexus 0.25.0 → 0.27.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/src/acp.ts CHANGED
@@ -86,15 +86,28 @@ export interface ACPOptions {
86
86
  /** One warm ACP connection: one child process, one session, alive across many
87
87
  * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
88
88
  * wants, so it plugs straight into {@link createInProcessClient}. */
89
+ /** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
90
+ export interface ACPGenerateOptions {
91
+ /** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
92
+ * the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
93
+ * turn never lands on a busy session, and the call rejects with an `AbortError`. The
94
+ * process and session stay warm. */
95
+ signal?: AbortSignal
96
+ }
97
+
89
98
  export interface ACPClient {
90
- generate: (request: InProcessRequest) => Promise<InProcessResponse>
99
+ generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>
91
100
  /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
92
101
  * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
93
102
  * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
94
103
  * (both via "acp": the agent's own tools, reported, never executed by the loop).
95
104
  * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
96
105
  * message text only. */
97
- generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>
106
+ generateStream: (
107
+ request: InProcessRequest,
108
+ emit: (event: StreamEvent) => void,
109
+ opts?: ACPGenerateOptions,
110
+ ) => Promise<InProcessResponse>
98
111
  /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
99
112
  close: () => Promise<void>
100
113
  /** The agent's raw `initialize` result. */
@@ -375,7 +388,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
375
388
  // queue itself never rejects (a failed turn does not poison later ones).
376
389
  let turnQueue: Promise<unknown> = Promise.resolve()
377
390
 
378
- async function promptOnce(request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> {
391
+ async function promptOnce(
392
+ request: InProcessRequest,
393
+ emit?: (event: StreamEvent) => void,
394
+ signal?: AbortSignal,
395
+ ): Promise<InProcessResponse> {
396
+ if (signal?.aborted) throw acpAbortError()
379
397
  const text = renderPrompt(request)
380
398
  const id = `c-${++nextId}`
381
399
 
@@ -440,21 +458,48 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
440
458
  )
441
459
  })
442
460
 
461
+ let onAbort: (() => void) | undefined
462
+ const cancelled = new Promise<"cancelled">((resolve) => {
463
+ if (!signal) return
464
+ onAbort = () => resolve("cancelled")
465
+ signal.addEventListener("abort", onAbort, { once: true })
466
+ })
467
+
443
468
  try {
444
- const msg = await Promise.race([replyPromise, timeout])
469
+ const msg = await Promise.race([replyPromise, timeout, cancelled])
470
+ if (msg === "cancelled") {
471
+ // ACP: session/cancel is a notification (no id); the agent then ends the turn with
472
+ // stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
473
+ write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } })
474
+ const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000
475
+ let wait: NodeJS.Timeout | undefined
476
+ await Promise.race([
477
+ replyPromise,
478
+ new Promise((r) => {
479
+ wait = setTimeout(r, waitMs)
480
+ }),
481
+ ])
482
+ clearTimeout(wait)
483
+ throw acpAbortError()
484
+ }
445
485
  if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
446
486
  if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
447
487
  return { content }
448
488
  } finally {
449
489
  clearTimeout(timer)
490
+ if (onAbort) signal!.removeEventListener("abort", onAbort)
450
491
  pending.delete(id)
451
492
  updateSink = null
452
493
  }
453
494
  }
454
495
 
455
- const generateStream = (request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> => {
496
+ const generateStream = (
497
+ request: InProcessRequest,
498
+ emit?: (event: StreamEvent) => void,
499
+ callOpts?: ACPGenerateOptions,
500
+ ): Promise<InProcessResponse> => {
456
501
  if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
457
- const turn = turnQueue.then(() => promptOnce(request, emit))
502
+ const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal))
458
503
  turnQueue = turn.then(
459
504
  () => undefined,
460
505
  () => undefined,
@@ -478,7 +523,8 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
478
523
  if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
479
524
  }
480
525
 
481
- const generate = (request: InProcessRequest): Promise<InProcessResponse> => generateStream(request)
526
+ const generate = (request: InProcessRequest, callOpts?: ACPGenerateOptions): Promise<InProcessResponse> =>
527
+ generateStream(request, undefined, callOpts)
482
528
 
483
529
  const client: ACPClient = { generate, generateStream, close, initializeResult }
484
530
  // Non-enumerable, undocumented — internal test hook only, so the public
@@ -487,6 +533,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
487
533
  return client
488
534
  }
489
535
 
536
+ function acpAbortError(): Error {
537
+ const e = new Error("toolnexus: ACP turn cancelled")
538
+ e.name = "AbortError"
539
+ return e
540
+ }
541
+
490
542
  /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
491
543
  function killGroup(pid: number, signal: NodeJS.Signals): void {
492
544
  if (process.platform !== "win32") {