@aragon-agent/core 0.2.15 → 0.2.17

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.
@@ -19,6 +19,20 @@ export interface ToolExecutorOptions {
19
19
  timeoutOverrides?: Record<string, number>;
20
20
  /** Maximum size (in bytes) of combined text output before truncation. Defaults to 100_000 (100 KB). */
21
21
  maxOutputSize?: number;
22
+ /**
23
+ * How long, after an abort is signalled, a tool has to settle before its
24
+ * promise is ABANDONED and an error result is returned in its place.
25
+ * Defaults to 5000.
26
+ *
27
+ * THE INVARIANT BEING PROTECTED IS "AN ABORT IS ALWAYS ANSWERABLE", AND AN
28
+ * INVARIANT THAT DEPENDS ON EVERY TOOL AUTHOR REMEMBERING IS NOT AN
29
+ * INVARIANT. Step 5 below used to be a bare `await tool.execute(...)`: the
30
+ * signal was delivered, the tool was free to ignore it, and the loop waited
31
+ * forever — so the host's "press Esc to stop" did nothing at all in exactly
32
+ * the situation where a user most needs it. Racing here makes the guarantee a
33
+ * property of the executor rather than of the toolset it happens to be given.
34
+ */
35
+ abortGraceMs?: number;
22
36
  }
23
37
  /**
24
38
  * Executes tool calls with validation, timeout management,
@@ -30,6 +44,7 @@ export declare class ToolExecutor {
30
44
  private readonly defaultTimeout;
31
45
  private readonly timeoutOverrides;
32
46
  private readonly maxOutputSize;
47
+ private readonly abortGraceMs;
33
48
  constructor(registry: ToolRegistry, options?: ToolExecutorOptions);
34
49
  /**
35
50
  * Execute a tool call end-to-end.
@@ -19,6 +19,13 @@ const DEFAULT_MAX_OUTPUT_SIZE = 100_000;
19
19
  const TRUNCATION_SUFFIX = '\n... [truncated]';
20
20
  /** Sentinel symbol used to identify timeout aborts vs external aborts. */
21
21
  const TIMEOUT_REASON = Symbol('tool-timeout');
22
+ /** Grace after an abort before a tool's promise is abandoned. */
23
+ const DEFAULT_ABORT_GRACE_MS = 5_000;
24
+ /**
25
+ * The race's losing branch. A unique object rather than a string or `null`, so
26
+ * it can never collide with something a tool legitimately resolves with.
27
+ */
28
+ const ABANDON = Symbol('tool-abandoned');
22
29
  // ---------------------------------------------------------------------------
23
30
  // ToolExecutor
24
31
  // ---------------------------------------------------------------------------
@@ -32,11 +39,13 @@ export class ToolExecutor {
32
39
  defaultTimeout;
33
40
  timeoutOverrides;
34
41
  maxOutputSize;
42
+ abortGraceMs;
35
43
  constructor(registry, options = {}) {
36
44
  this.registry = registry;
37
45
  this.defaultTimeout = options.defaultTimeout ?? DEFAULT_TIMEOUT_MS;
38
46
  this.timeoutOverrides = options.timeoutOverrides ?? {};
39
47
  this.maxOutputSize = options.maxOutputSize ?? DEFAULT_MAX_OUTPUT_SIZE;
48
+ this.abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
40
49
  }
41
50
  /**
42
51
  * Execute a tool call end-to-end.
@@ -88,14 +97,67 @@ export class ToolExecutor {
88
97
  }
89
98
  signal.addEventListener('abort', externalAbortHandler, { once: true });
90
99
  }
91
- // 5. Execute the tool.
100
+ // 5. Execute the tool, RACED AGAINST THE ABORT (+ grace).
101
+ //
102
+ // The race is the whole of the hardening: `tool.execute` may never settle —
103
+ // a child process whose grandchild holds the stdout pipe open is the shipped
104
+ // example — and without a second branch the loop waits on it forever while
105
+ // the user's abort has already been delivered and acted on.
106
+ //
107
+ // CLEANED UP IN A `finally`, NOT LEFT TO `{ once: true }`. The listener is
108
+ // one-shot, but the GRACE TIMER it arms is not: a tool that wins the race
109
+ // after an abort would leave a timer to fire `abortGraceMs` later, harmless
110
+ // today only because it is `unref`'d — a property of the timer rather than
111
+ // of the design. `agent-loop.ts`'s compaction race is the in-repo precedent
112
+ // and does exactly this.
92
113
  let result;
114
+ let graceTimer;
115
+ let onAbandonAbort;
93
116
  try {
94
117
  const context = {
95
118
  signal: controller.signal,
96
119
  onProgress,
97
120
  };
98
- result = await tool.execute(toolCallId, validatedParams, context);
121
+ const abandoned = new Promise((resolveAbandon) => {
122
+ const arm = () => {
123
+ // WRITTEN ON THE CONTEXT OBJECT THE TOOL ALREADY HOLDS, before its own
124
+ // abort listener runs, so a tool can tell "the ceiling killed me" from
125
+ // "the user pressed Esc" and say so in its result.
126
+ context.abortCause =
127
+ controller.signal.reason === TIMEOUT_REASON ? 'timeout' : 'external';
128
+ graceTimer = setTimeout(() => resolveAbandon(ABANDON), this.abortGraceMs);
129
+ graceTimer.unref?.();
130
+ };
131
+ if (controller.signal.aborted) {
132
+ arm();
133
+ return;
134
+ }
135
+ onAbandonAbort = arm;
136
+ controller.signal.addEventListener('abort', arm, { once: true });
137
+ });
138
+ const outcome = await Promise.race([
139
+ // Mapped to a VALUE on both settlements, so a rejecting tool does not
140
+ // reject the race itself and skip the `finally` below; the throw is
141
+ // re-thrown one line later into the existing catch, unchanged.
142
+ tool.execute(toolCallId, validatedParams, context).then((r) => r, (e) => ({ __thrown: e })),
143
+ abandoned,
144
+ ]);
145
+ if (outcome === ABANDON) {
146
+ // The orphan is DISCARDED. Its rejection is already absorbed by the
147
+ // `.then(onFulfilled, onRejected)` above rather than by a later
148
+ // `.catch()`, which is why that mapping is written as a two-argument
149
+ // `then` and not as `await`: a rejection arriving after we stopped
150
+ // listening would otherwise be an `unhandledRejection`, and in a host
151
+ // that routes those to a fatal handler a successfully-abandoned tool
152
+ // would become a process exit.
153
+ result = errorResult(`Tool "${toolName}" did not stop within ${this.abortGraceMs}ms of abort; abandoned.`);
154
+ }
155
+ else if (typeof outcome === 'object' && outcome !== null && '__thrown' in outcome) {
156
+ throw outcome.__thrown;
157
+ }
158
+ else {
159
+ result = outcome;
160
+ }
99
161
  }
100
162
  catch (err) {
101
163
  if (isAbortError(err) || controller.signal.aborted) {
@@ -111,6 +173,11 @@ export class ToolExecutor {
111
173
  }
112
174
  finally {
113
175
  clearTimeout(timeoutId);
176
+ if (graceTimer)
177
+ clearTimeout(graceTimer);
178
+ if (onAbandonAbort) {
179
+ controller.signal.removeEventListener('abort', onAbandonAbort);
180
+ }
114
181
  if (signal) {
115
182
  signal.removeEventListener('abort', externalAbortHandler);
116
183
  }
@@ -67,6 +67,23 @@ export interface ToolExecutionContext {
67
67
  signal?: AbortSignal;
68
68
  /** Optional callback for reporting incremental progress. */
69
69
  onProgress?: (update: ToolProgressUpdate) => void;
70
+ /**
71
+ * WHY the signal fired, set by `ToolExecutor` on THIS object at abort time,
72
+ * before the tool's own `abort` listener runs.
73
+ *
74
+ * The executor already distinguishes the two — its `TIMEOUT_REASON` symbol is
75
+ * module-private and carried on `controller.signal.reason` — but a tool cannot
76
+ * read it, because the symbol is not exported and must not be (the runtime
77
+ * export surface is frozen by `public-api.test.ts`). Publishing the CAUSE
78
+ * rather than the symbol keeps that surface unchanged while letting a tool
79
+ * write an honest footer: a command killed by the 180 s ceiling and one the
80
+ * user interrupted with Esc want different words, and only one of them should
81
+ * be advised to retry with a background launch.
82
+ *
83
+ * A FIELD ON AN EXISTING INTERFACE, so no new exported name appears. A tool
84
+ * that ignores it behaves exactly as it does today.
85
+ */
86
+ abortCause?: 'timeout' | 'external';
70
87
  }
71
88
  /**
72
89
  * The function signature every tool must implement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "AragonAgent core engine — LLM provider adapters, streaming, tool registry/execution, steering, idle watchdog, and an optional isolated-vm sandbox.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",