@aragon-agent/core 0.2.15 → 0.2.16
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/dist/tools/executor.d.ts +15 -0
- package/dist/tools/executor.js +69 -2
- package/dist/tools/types.d.ts +17 -0
- package/package.json +1 -1
package/dist/tools/executor.d.ts
CHANGED
|
@@ -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.
|
package/dist/tools/executor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.2.16",
|
|
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",
|