@matatbread/matbot-core 0.4.8 → 0.4.9

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/package.json +2 -2
  2. package/src/runner.ts +47 -1
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.8",
7
+ "version": "0.4.9",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.8"
38
+ "@matatbread/matbot-plugin-api": "^0.4.9"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/runner.ts CHANGED
@@ -44,6 +44,34 @@ const TRUNCATION_CREATOR = 'matbot-truncation';
44
44
  const abortReason = (signal: AbortSignal): string =>
45
45
  typeof signal.reason === 'string' ? signal.reason : 'user-abort';
46
46
 
47
+ /** How long a tool executor may keep the turn alive AFTER the turn was aborted, before the runner stops
48
+ * reading it. An abort must abort: any executor can fail to end — a child process whose stdout was
49
+ * inherited by an orphan, a generator awaiting something that never settles, a bridged remote that went
50
+ * away — and the runner awaiting it is then the only thing holding a turn that nothing left in the
51
+ * process can end. Abandoning is bounded rather than immediate because a well-behaved tool cleaning up
52
+ * after a cut-off (flushing a partial result, closing a handle) is the common case and worth waiting for.
53
+ * Only armed once `signal` is aborted, so a long-running tool on a healthy turn is never cut short. */
54
+ const ABANDONED_TOOL_GRACE_MS = 30_000;
55
+
56
+ /** Resolves `ABANDON` once the signal has been aborted for `ms`, and never otherwise. */
57
+ const ABANDON = Symbol('abandon');
58
+ function abandonDeadline(signal: AbortSignal, ms: number): { wait: Promise<typeof ABANDON>; dispose: () => void } {
59
+ let timer: ReturnType<typeof setTimeout> | undefined;
60
+ let arm: (() => void) | undefined;
61
+ const wait = new Promise<typeof ABANDON>(resolve => {
62
+ arm = () => { timer = setTimeout(() => resolve(ABANDON), ms); };
63
+ if (signal.aborted) arm();
64
+ else signal.addEventListener('abort', arm, { once: true });
65
+ });
66
+ return {
67
+ wait,
68
+ dispose: () => {
69
+ if (timer !== undefined) clearTimeout(timer);
70
+ if (arm !== undefined) signal.removeEventListener('abort', arm);
71
+ },
72
+ };
73
+ }
74
+
47
75
  const INTERRUPTED_TOOL_RESULT = {
48
76
  error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
49
77
  } as const;
@@ -562,8 +590,24 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
562
590
  ...(opts.files !== undefined ? { files: opts.files } : {}),
563
591
  };
564
592
 
593
+ // Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
594
+ // aborted (see ABANDONED_TOOL_GRACE_MS): an executor that never returns would otherwise hold this
595
+ // turn open for ever, with no path back for the session.
596
+ const events = siteScoped(site, tool.executor.execute(tc.input, toolCtx))[Symbol.asyncIterator]();
597
+ const deadline = abandonDeadline(signal, ABANDONED_TOOL_GRACE_MS);
565
598
  try {
566
- for await (const toolEv of siteScoped(site, tool.executor.execute(tc.input, toolCtx))) {
599
+ for (;;) {
600
+ const step = await Promise.race([events.next(), deadline.wait]);
601
+ if (step === ABANDON) {
602
+ console.warn(`[matbot] tool "${tc.name}" did not return ${ABANDONED_TOOL_GRACE_MS}ms after the turn was aborted — abandoned.`);
603
+ // Not awaited: a tool that ignored the abort may well not return from `return()` either, and
604
+ // awaiting it here is the same hang one frame further out.
605
+ void events.return?.(undefined as never);
606
+ isError = true;
607
+ break;
608
+ }
609
+ if (step.done === true) break;
610
+ const toolEv = step.value;
567
611
  switch (toolEv.type) {
568
612
  case 'stdout': yield { type: 'tool:stdout', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
569
613
  case 'stderr': yield { type: 'tool:stderr', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
@@ -582,6 +626,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
582
626
  } catch (e) {
583
627
  result = { error: String(e) };
584
628
  isError = true;
629
+ } finally {
630
+ deadline.dispose();
585
631
  }
586
632
 
587
633
  // A tool that errored under an aborted signal did so because it was cut off (a steer interrupt,