@aragon-agent/core 0.2.14 → 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.
@@ -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/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { AssistantMessage, Message, StreamEvent, TokenUsage } from './llm/types.js';
8
8
  import type { ToolResult } from './tools/types.js';
9
+ import type { CompactionTrigger } from './engine/context-manager.js';
9
10
  export interface AgentStartEvent {
10
11
  type: 'agent_start';
11
12
  }
@@ -50,7 +51,62 @@ export interface CodeExecutionEndEvent {
50
51
  error?: string;
51
52
  duration: number;
52
53
  }
54
+ /**
55
+ * Context compaction is about to run (context-auto-compaction §5.1).
56
+ *
57
+ * IT PAUSES THE IDLE WATCHDOG (`agent.ts::applyWatchdogPolicy`), because a
58
+ * summarization is a normal LLM call and can exceed `DEFAULT_IDLE_TIMEOUT`.
59
+ */
60
+ export interface CompactionStartEvent {
61
+ type: 'compaction_start';
62
+ trigger: CompactionTrigger;
63
+ messageCount: number;
64
+ }
65
+ /**
66
+ * Context compaction finished, applied or not (context-auto-compaction §5.1).
67
+ *
68
+ * EMITTED FROM A `finally`, ON EVERY EXIT PATH. It is what RESUMES the watchdog,
69
+ * so a compaction that failed to emit its `end` would leave the run permanently
70
+ * deaf (P1-3).
71
+ */
72
+ export interface CompactionEndEvent {
73
+ type: 'compaction_end';
74
+ applied: boolean;
75
+ mode: 'summarized' | 'truncated' | 'none';
76
+ /** Set whenever `applied` is false, and on any degraded splice. */
77
+ reason?: string;
78
+ messagesBefore: number;
79
+ messagesAfter: number;
80
+ droppedMessages: number;
81
+ /**
82
+ * Core's own `estimatePromptTokens`, BOTH SIDES — comparable by construction.
83
+ *
84
+ * A "before" from provider usage and an "after" from an estimate are two
85
+ * different units, and the card's headline claim ("118k -> 23k") would be
86
+ * comparing a measurement to a guess (§5.1).
87
+ */
88
+ estimatedTokensBefore: number;
89
+ estimatedTokensAfter: number;
90
+ /** The summary body, so the host needs no second channel to render its card. */
91
+ summary?: string;
92
+ /**
93
+ * Set only when the host clipped oversized `tool_result` bodies inside the
94
+ * RETAINED tail because the tail alone did not fit
95
+ * (context-auto-compaction-hardening §3.3.4 / W2).
96
+ *
97
+ * OPTIONAL, SO NO EXISTING SUBSCRIBER CHANGES. It is here rather than on
98
+ * `CompactionOutcome` because the engine has no use for the distinction — it
99
+ * validates and adopts a history like any other — while `aragon exec`'s JSON
100
+ * stream and the headless stderr line report a bounded, announced data loss
101
+ * the user is entitled to know about.
102
+ */
103
+ tailRelief?: {
104
+ messages: number;
105
+ charsRemoved: number;
106
+ };
107
+ durationMs: number;
108
+ }
53
109
  /** Union of all events emitted by the Agent engine. */
54
- export type AgentEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | MessageUpdateEvent | ToolExecutionStartEvent | ToolExecutionEndEvent | CodeExecutionStartEvent | CodeExecutionEndEvent;
110
+ export type AgentEvent = AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | MessageUpdateEvent | ToolExecutionStartEvent | ToolExecutionEndEvent | CodeExecutionStartEvent | CodeExecutionEndEvent | CompactionStartEvent | CompactionEndEvent;
55
111
  /** Callback signature for Agent event listeners. */
56
112
  export type AgentEventListener = (event: AgentEvent) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.14",
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",