@jini-ai/daemon 0.6.7 → 0.6.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 (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +26 -3
  18. package/dist/core/read-only-tools.js +38 -5
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-candidates.d.ts +8 -0
  22. package/dist/core/tool-candidates.js +6 -0
  23. package/dist/core/tool-gaps.d.ts +60 -0
  24. package/dist/core/tool-gaps.js +199 -0
  25. package/dist/delegated-tool-bridge.d.ts +1 -1
  26. package/dist/delegated-tool-bridge.js +161 -122
  27. package/dist/event-log.d.ts +45 -2
  28. package/dist/event-log.js +83 -4
  29. package/dist/http/attachments.d.ts +26 -1
  30. package/dist/http/attachments.js +129 -65
  31. package/dist/http/connectors.d.ts +1 -32
  32. package/dist/http/connectors.js +13 -68
  33. package/dist/http/delegated-tools.d.ts +5 -1
  34. package/dist/http/delegated-tools.js +29 -29
  35. package/dist/http/host-tools.d.ts +1 -0
  36. package/dist/http/host-tools.js +64 -59
  37. package/dist/http/index.d.ts +2 -2
  38. package/dist/http/index.js +1 -1
  39. package/dist/http/media.js +48 -69
  40. package/dist/http/memory.js +45 -36
  41. package/dist/http/model-proxy-fields.d.ts +23 -0
  42. package/dist/http/model-proxy-fields.js +29 -0
  43. package/dist/http/model-proxy.js +193 -395
  44. package/dist/http/research-tavily.adapter.js +72 -49
  45. package/dist/http/research.js +33 -23
  46. package/dist/http/resource-request.d.ts +37 -0
  47. package/dist/http/resource-request.js +59 -0
  48. package/dist/http/routines.js +49 -90
  49. package/dist/http/runs.d.ts +7 -5
  50. package/dist/http/runs.js +21 -7
  51. package/dist/http/terminals.js +23 -48
  52. package/dist/http/tool-catalog.d.ts +5 -0
  53. package/dist/http/xai.d.ts +11 -1
  54. package/dist/http/xai.js +125 -124
  55. package/dist/legacy-data-migration.js +41 -24
  56. package/dist/run/core/retry.js +51 -35
  57. package/dist/run/diagnostics/diagnostics.js +5 -17
  58. package/dist/run-credentials.js +49 -27
  59. package/dist/run-lifecycle.d.ts +12 -2
  60. package/dist/run-lifecycle.js +47 -26
  61. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  62. package/dist/store/agent-sessions/sqlite.js +6 -4
  63. package/dist/surface-exchanges.js +2 -1
  64. package/dist/terminal-session.d.ts +1 -1
  65. package/dist/terminal-session.js +26 -33
  66. package/dist/tool-audit.d.ts +1 -0
  67. package/dist/tool-audit.js +1 -0
  68. package/dist/tool-executor.d.ts +24 -3
  69. package/dist/tool-executor.js +277 -139
  70. package/dist/tool-recovery/recovery.js +64 -35
  71. package/dist/tool-recovery/stack.d.ts +2 -1
  72. package/dist/tool-recovery/stack.js +1 -1
  73. package/package.json +10 -10
@@ -46,8 +46,18 @@
46
46
  * answer gets back in.
47
47
  */
48
48
  import { randomUUID } from 'node:crypto';
49
- import { ToolInputError, } from '@jini-ai/core';
49
+ import { ToolInputError, withNormalizationNotes, ToolInputNormalizationError, } from '@jini-ai/core';
50
50
  import { authorizeToolInvocation } from '@jini-ai/core/composition';
51
+ import { toolCandidates } from './core/tool-candidates.js';
52
+ /**
53
+ * Default cap on finished audit records kept for `getAuditRecord`. One record is added per tool
54
+ * execution and nothing else ever removed one, so a long-lived daemon grew the map for its whole
55
+ * uptime (memory investigation 2026-10-21). An in-flight execution's record is never evicted: its
56
+ * later transitions still have to land on it.
57
+ */
58
+ export const DEFAULT_MAX_AUDIT_RECORDS = 1000;
59
+ /** Phases after which an execution's audit record receives no further events. */
60
+ const TERMINAL_AUDIT_PHASES = new Set(['denied', 'confirmation-denied', 'completed', 'failed', 'timed-out', 'cancelled']);
51
61
  /**
52
62
  * Enforces `maxOutputBytes` in BYTES, which is what the option is named for.
53
63
  *
@@ -81,14 +91,18 @@ function truncateOutput(output, maxOutputBytes) {
81
91
  * — the one and only caller of that internal export.
82
92
  * @param options.delegate - Transport-supplied authorize/confirm UI seam;
83
93
  * omit for a headless caller whose tools never need interactive gating.
84
- * @complexity Known-id `execute` is O(1) plus the handler's own cost; unknown ids scan O(n) descriptors for hints. `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
94
+ * @param options.maxAuditRecords - Finished audit records kept, see {@link DEFAULT_MAX_AUDIT_RECORDS}.
95
+ * @complexity Known-id `execute` adds schema-guided normalization to the handler's cost; unknown ids scan O(n) descriptors for hints. `resumeConfirmation`/`cancel`/`getAuditRecord` are O(1) map lookups.
85
96
  * @overallScore 100/100
86
97
  */
87
98
  export function createToolExecutor(requiredArgs, optionalArgs = {}) {
88
99
  const options = { ...requiredArgs, ...optionalArgs };
89
100
  const { registry, delegate = {} } = options;
90
101
  const now = options.now ?? Date.now;
102
+ const maxAuditRecords = options.maxAuditRecords ?? DEFAULT_MAX_AUDIT_RECORDS;
91
103
  const audits = new Map();
104
+ // Finished executionIds, oldest first (Set insertion order): the only records `retireAudit` evicts.
105
+ const finishedAudits = new Set();
92
106
  const pendingConfirmations = new Map();
93
107
  const activeControllers = new Map();
94
108
  /**
@@ -107,6 +121,19 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
107
121
  // defensive `if (!record) return` guard with no reachable path to hit it.
108
122
  const record = audits.get(executionId);
109
123
  record.events.push(detail === undefined ? { phase, at: now() } : { phase, at: now(), detail });
124
+ // A terminal transition is each execution's last append (`execute()` returns right after it),
125
+ // so evicting finished records can never pull one out from under a later `appendEvent`.
126
+ if (TERMINAL_AUDIT_PHASES.has(phase))
127
+ retireAudit(executionId);
128
+ }
129
+ /** Marks `executionId` finished and evicts the oldest finished records past `maxAuditRecords`. */
130
+ function retireAudit(executionId) {
131
+ finishedAudits.add(executionId);
132
+ while (finishedAudits.size > maxAuditRecords) {
133
+ const oldest = finishedAudits.values().next().value;
134
+ finishedAudits.delete(oldest);
135
+ audits.delete(oldest);
136
+ }
110
137
  }
111
138
  function requestConfirmation(request) {
112
139
  if (delegate.onConfirm) {
@@ -132,7 +159,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
132
159
  appendEvent(executionId, 'requested');
133
160
  return executionId;
134
161
  }
135
- function authorize(principal, run, toolId, input) {
162
+ function authorize({ principal, run, toolId, input, registry }) {
136
163
  // `.bind(delegate)` rather than passing the bare method reference: `ExecutionDelegate` is
137
164
  // declared with method syntax, so a host may legitimately implement it as a class instance
138
165
  // whose `onAuthorize` reads `this`. Handing the detached function to `@jini-ai/core` would
@@ -159,135 +186,174 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
159
186
  handle.unref?.();
160
187
  return { handle, timedOut: () => timedOut };
161
188
  }
162
- /** Classifies a handler rejection into its terminal result, recording the matching transition. */
163
- function failureResult(executionId, err, timedOut, aborted) {
164
- if (timedOut) {
165
- appendEvent(executionId, 'timed-out');
166
- return { executionId, status: 'timed-out' };
167
- }
168
- if (aborted) {
169
- appendEvent(executionId, 'cancelled');
170
- return { executionId, status: 'cancelled' };
171
- }
172
- const message = err instanceof Error ? err.message : String(err);
189
+ /** Records a terminal transition that carries no detail and returns its bare result. */
190
+ function terminal({ executionId, status }) {
191
+ appendEvent(executionId, status);
192
+ return { executionId, status };
193
+ }
194
+ /** Records an operational (non-decision) failure under a fixed message that never carries the exception text. */
195
+ function operationalFailure({ executionId, error }) {
196
+ appendEvent(executionId, 'failed', error);
197
+ return { executionId, status: 'failed', error };
198
+ }
199
+ /**
200
+ * Classifies a handler rejection into its terminal result, recording the matching transition.
201
+ * Prepared `guidance` is appended to a `failed` result's error (never to its audit detail) — a
202
+ * rejected write still returns a model-visible result; do not lose prepared guidance there.
203
+ */
204
+ function failureResult({ executionId, err, timedOut, aborted }, { normalizationNotes = [], guidance = '' } = {}) {
205
+ if (timedOut)
206
+ return terminal({ executionId, status: 'timed-out' });
207
+ if (aborted)
208
+ return terminal({ executionId, status: 'cancelled' });
209
+ const message = withNormalizationNotes({ output: err instanceof Error ? err.message : String(err), notes: normalizationNotes });
173
210
  appendEvent(executionId, 'failed', message);
174
211
  const errorKind = err instanceof ToolInputError ? 'validation' : 'internal';
175
- return { executionId, status: 'failed', error: message, errorKind };
212
+ const error = guidance ? `${message}\n\n${guidance}` : message;
213
+ return { executionId, status: 'failed', error, errorKind, ...(normalizationNotes.length ? { normalizationNotes } : {}) };
176
214
  }
177
- async function execute({ principal, run, toolId, input }, { signal, emitSurface } = {}) {
178
- // Existence is checked separately from — and before — authorization so the audit record can
179
- // be opened before the (possibly slow, possibly throwing) authorization gate runs, matching
180
- // pre-authorizeToolInvocation timing: `requested` used to precede `policy.authorize`, and a
181
- // policy that hangs or throws used to still leave a record behind. `has()` only confirms
182
- // existence, never returns a handler, so this doesn't reopen the leak authorizeToolInvocation
183
- // closed — that gate still runs, still unconditionally, inside the call below.
184
- if (!registry.has({ toolId })) {
185
- const candidates = registry.list({}).map((tool) => tool.id).filter((id) => id.startsWith(toolId)).slice(0, 8);
186
- const hint = candidates.length > 0 ? `; did you mean ${candidates.join(', ')}?` : '';
187
- throw new ToolInputError({ message: `ToolExecutor: unknown tool "${toolId}"${hint}` });
215
+ /** Throws the typed unknown-tool input error, naming up to eight registered prefix matches. */
216
+ function assertKnownTool({ registry: invocationRegistry, toolId }) {
217
+ if (invocationRegistry.has({ toolId }))
218
+ return;
219
+ const candidates = toolCandidates({ toolId, tools: invocationRegistry.list({}) });
220
+ const hint = candidates.length > 0 ? `; did you mean ${candidates.join(', ')}?` : '';
221
+ throw new ToolInputError({ message: `ToolExecutor: unknown tool "${toolId}"${hint}` });
222
+ }
223
+ /**
224
+ * Routes a transport `signal` firing into `cancel(executionId)` and returns the function that
225
+ * detaches it again. A no-op pair when the transport supplied no signal.
226
+ */
227
+ function watchTransportAbort({ executionId, signal }) {
228
+ if (!signal)
229
+ return () => undefined;
230
+ const onTransportAbort = () => cancel({ executionId: executionId });
231
+ signal.addEventListener('abort', onTransportAbort, { once: true });
232
+ return () => signal.removeEventListener('abort', onTransportAbort);
233
+ }
234
+ /** Terminal result for an authorization gate that threw, keeping a typed normalization error's message. */
235
+ function authorizationFailure({ executionId, error }) {
236
+ if (error instanceof ToolInputNormalizationError)
237
+ return failureResult({ executionId, err: error, timedOut: false, aborted: false });
238
+ return operationalFailure({ executionId, error: 'authorization failed' });
239
+ }
240
+ /**
241
+ * Acts on the authorization outcome synchronously: `{ refusal }` if the call ends here
242
+ * (cancelled meanwhile, or denied), otherwise records `authorized` and returns `{ allowed }`.
243
+ */
244
+ function admitAuthorized({ executionId, resolved, signal }) {
245
+ // `authorize()` isn't itself signal-aware — it's typically a fast policy check, not a
246
+ // human-in-the-loop wait — so this is a post-await check rather than a mid-flight abort, the
247
+ // same idiom the post-handler check below already uses: a signal that fired while `authorize()`
248
+ // was in flight is caught here, before its (now-moot) decision is acted on.
249
+ if (signal?.aborted)
250
+ return { refusal: terminal({ executionId, status: 'cancelled' }) };
251
+ if (resolved.decision !== 'allow')
252
+ return { refusal: terminal({ executionId, status: 'denied' }) };
253
+ const { normalizationNotes } = resolved;
254
+ appendEvent(executionId, 'authorized', normalizationNotes.length ? `normalized: ${normalizationNotes.join('; ')}` : undefined);
255
+ return { allowed: resolved };
256
+ }
257
+ /**
258
+ * Runs the confirmation gate: `undefined` once confirmed (recorded), otherwise the terminal result.
259
+ *
260
+ * Only ever awaited on the confirmation path. It was once kept inline in `execute()` because
261
+ * hoisting it into an `async` function that every call awaited added microtask ticks to the
262
+ * *un*-confirmed path too, and callers observe that timing — a test cancels an in-flight
263
+ * execution after a fixed number of ticks and needs the handler to have already started by then.
264
+ * `execute()` awaits this only inside its `requiresConfirmation` branch, so the un-confirmed path
265
+ * keeps its tick count, and `requestConfirmation()` still runs synchronously on entry.
266
+ */
267
+ async function confirmGate(request) {
268
+ const { executionId } = request;
269
+ let confirmation;
270
+ try {
271
+ confirmation = await requestConfirmation(request);
188
272
  }
189
- const executionId = openAudit(principal, run, toolId);
190
- // Observes `signal` for this execution's ENTIRE remaining lifetime, not just the handler-run
191
- // phase. `requestConfirmation()`'s wait below is unbounded — it settles only when a human
192
- // answers or `resumeConfirmation`/`cancel` is called — so a transport disconnect or run-cancel
193
- // that only reached a handler-phase `AbortController` (this file's previous design) had no way
194
- // to end a still-parked confirmation; `execute()` simply never settled. Routed through the
195
- // existing `cancel(executionId)` rather than a bespoke handler, so every trigger — an explicit
196
- // `cancel()` call, a run cancellation, and now a transport disconnect — resolves a pending
197
- // confirmation and an in-flight handler the exact same way.
198
- let onTransportAbort;
199
- function detachTransportAbort() {
200
- if (onTransportAbort)
201
- signal?.removeEventListener('abort', onTransportAbort);
273
+ catch {
274
+ // Same reasoning as the authorization gate: terminal, recorded, and generic.
275
+ return operationalFailure({ executionId, error: 'confirmation failed' });
202
276
  }
203
- if (signal) {
204
- if (signal.aborted) {
205
- appendEvent(executionId, 'cancelled');
206
- return { executionId, status: 'cancelled' };
207
- }
208
- onTransportAbort = () => cancel({ executionId: executionId });
209
- signal.addEventListener('abort', onTransportAbort, { once: true });
277
+ if (confirmation === 'confirm') {
278
+ appendEvent(executionId, 'confirmed');
279
+ return undefined;
210
280
  }
211
- // Both gates below can throw on INFRASTRUCTURE failure — a policy that rejects, a confirmation
212
- // transport that drops. Those throws used to escape `execute` entirely, which cost two things:
213
- // the audit record stayed open forever at `requested`/`authorized` with no terminal transition
214
- // (the comment above only ever guarded against leaving NO record, not against leaving an
215
- // unfinished one), and the raw exception text travelled outward to become tool-result content
216
- // an agent reads — an authorization or transport internal surfaced to the model verbatim.
217
- //
218
- // Recorded terminal and returned as `failed` with a fixed message. `failed` is the right state:
219
- // this execution ended, and it ended for an operational reason rather than a decision. The real
220
- // exception stays server-side. Deliberately narrower than the `unknown tool` throw above, which
221
- // stays a typed input-error throw because no tool execution exists to settle.
222
- let resolved;
281
+ // `cancel(executionId)` (called directly, or via the transport-abort listener) resolves a
282
+ // pending confirmation the same way a human `deny` does, but the two are not the same OUTCOME:
283
+ // `confirmation-denied` asserts a decision was made, which is false when the requester vanished
284
+ // before answering. `cancelledConfirmations` distinguishes them so the audit trail never
285
+ // reports a decision nobody made.
286
+ if (cancelledConfirmations.delete(executionId))
287
+ return terminal({ executionId, status: 'cancelled' });
288
+ return terminal({ executionId, status: 'confirmation-denied' });
289
+ }
290
+ /** Advisory guidance for an authorized call. A failure is fail-open: it yields no guidance. */
291
+ async function prepareGuidanceSafely({ principal, run, tool, signal }, { emitSurface }) {
223
292
  try {
224
- resolved = await authorize(principal, run, toolId, input);
293
+ return await options.prepareGuidance({ principal, run, tool, signal,
294
+ ...(emitSurface ? { emitSurface } : {}) }, {});
225
295
  }
226
296
  catch {
227
- detachTransportAbort();
228
- appendEvent(executionId, 'failed', 'authorization failed');
229
- return { executionId, status: 'failed', error: 'authorization failed' };
297
+ // Guidance availability must never convert an authorized write into a tool failure.
298
+ return '';
230
299
  }
231
- // `authorize()` isn't itself signal-aware — it's typically a fast policy check, not a
232
- // human-in-the-loop wait — so this is a post-await check rather than a mid-flight abort, the
233
- // same idiom the post-handler check below already uses: a signal that fired while `authorize()`
234
- // was in flight is caught here, before its (now-moot) decision is acted on.
235
- if (signal?.aborted) {
236
- detachTransportAbort();
237
- appendEvent(executionId, 'cancelled');
238
- return { executionId, status: 'cancelled' };
239
- }
240
- // `registry.has({ toolId })` above already confirmed the tool exists, and `ToolRegistry` is
241
- // append-only (no unregister — see `createToolRegistry`'s doc), so `authorizeToolInvocation`
242
- // resolving the same `toolId` against the same `registry` cannot come back `undefined` here.
243
- const { descriptor } = resolved;
244
- if (resolved.decision !== 'allow') {
245
- detachTransportAbort();
246
- appendEvent(executionId, 'denied');
247
- return { executionId, status: 'denied' };
300
+ }
301
+ /** Whether `prepareGuidance` applies to this tool — checked synchronously so the no-guidance path adds no tick. */
302
+ function wantsGuidance(descriptor) {
303
+ return Boolean(options.prepareGuidance && descriptor.metadata?.guidanceCapabilities?.length);
304
+ }
305
+ /** `{ emitSurface }` when the transport supplied one, else `{}` — see `runHandler` for why it is never defaulted. */
306
+ function surfaceOption(emitSurface) {
307
+ return emitSurface !== undefined ? { emitSurface } : {};
308
+ }
309
+ /** The handler's `invokeTool` port: a nested call through this same executor and its gates. */
310
+ function nestedInvoker({ principal, run, toolId, invocationRegistry, signal }, { emitSurface }) {
311
+ return async ({ toolId: nestedToolId, input: nestedInput }, { registry: nestedRegistry = invocationRegistry } = {}) => {
312
+ const selectedRegistry = nestedToolId === toolId || invocationRegistry.has({ toolId: nestedToolId }) ? invocationRegistry : nestedRegistry;
313
+ const result = await execute({ principal, run, toolId: nestedToolId, input: nestedInput }, { signal, ...surfaceOption(emitSurface), invocationRegistry: selectedRegistry });
314
+ if (result.status === 'completed')
315
+ return result.output;
316
+ // `failed` results always carry `error` (and only they carry `errorKind`); the bare terminal
317
+ // statuses carry none, so they are named by tool id and status instead.
318
+ const message = result.error ?? `${nestedToolId}: ${result.status}`;
319
+ throw result.errorKind === 'validation' ? new ToolInputError({ message }) : new Error(message);
320
+ };
321
+ }
322
+ /**
323
+ * Turns a resolved handler output into the terminal result: `completed` with the (guidance-
324
+ * annotated, normalization-noted, truncated) output, or the terminal state the call already
325
+ * reached when the handler resolved after it.
326
+ */
327
+ function settleOutput({ executionId, rawOutput, descriptor, timedOut, aborted }, { guidance, normalizationNotes }) {
328
+ // A handler is not OBLIGED to honour `signal` — it is handed one, not policed by one. A
329
+ // handler that ignores it and resolves normally after the timer fired (or after `cancel`)
330
+ // used to land here and be recorded `completed`, because the timeout/abort state was only
331
+ // ever consulted in the `catch`. That reports success for a call the contract has already
332
+ // declared terminal in the other direction: the caller was told `timed-out`/`cancelled` is
333
+ // final (see this module's state-machine doc), the audit trail said `completed`, and the two
334
+ // disagreed. Whether the handler cooperated does not change what happened to the CALL.
335
+ if (timedOut || aborted) {
336
+ return failureResult({ executionId, err: new Error('handler resolved after the execution was already terminal'), timedOut, aborted });
248
337
  }
249
- appendEvent(executionId, 'authorized');
250
- const { handler } = resolved;
251
- // Kept inline rather than extracted into a helper: hoisting this into an `async` function adds
252
- // microtask ticks to the *un*-confirmed path too, and callers observe that timing — a test
253
- // cancels an in-flight execution after a fixed number of ticks and needs the handler to have
254
- // already started by then.
255
- if (descriptor.requiresConfirmation) {
256
- let confirmation;
257
- try {
258
- confirmation = await requestConfirmation({ executionId, principal, run, tool: descriptor, input });
259
- }
260
- catch {
261
- // Same reasoning as the authorization gate above: terminal, recorded, and generic.
262
- detachTransportAbort();
263
- appendEvent(executionId, 'failed', 'confirmation failed');
264
- return { executionId, status: 'failed', error: 'confirmation failed' };
265
- }
266
- if (confirmation !== 'confirm') {
267
- detachTransportAbort();
268
- // `cancel(executionId)` (called directly, or via the transport-abort listener above)
269
- // resolves a pending confirmation the same way a human `deny` does, but the two are not the
270
- // same OUTCOME: `confirmation-denied` asserts a decision was made, which is false when the
271
- // requester vanished before answering. `cancelledConfirmations` distinguishes them so the
272
- // audit trail never reports a decision nobody made.
273
- if (cancelledConfirmations.delete(executionId)) {
274
- appendEvent(executionId, 'cancelled');
275
- return { executionId, status: 'cancelled' };
276
- }
277
- appendEvent(executionId, 'confirmation-denied');
278
- return { executionId, status: 'confirmation-denied' };
279
- }
280
- appendEvent(executionId, 'confirmed');
338
+ const { output, truncated } = truncateOutput(withNormalizationNotes({ output: attachToolGuidance({ output: rawOutput, guidance }), notes: normalizationNotes }), descriptor.maxOutputBytes);
339
+ appendEvent(executionId, 'completed');
340
+ return { executionId, status: 'completed', output, truncated, ...(normalizationNotes.length ? { normalizationNotes } : {}) };
341
+ }
342
+ /**
343
+ * Prepares guidance, arms the timeout and runs the handler. Runs synchronously up to the handler
344
+ * call when no guidance applies, so the handler has started by the time `execute()`'s caller
345
+ * observes the same tick count as before this was extracted.
346
+ */
347
+ async function runHandler(call, { emitSurface }) {
348
+ const { executionId, controller, principal, run, input, descriptor, handler, normalizationNotes } = call;
349
+ const guidance = wantsGuidance(descriptor)
350
+ ? await prepareGuidanceSafely({ principal, run, tool: descriptor, signal: controller.signal }, { emitSurface })
351
+ : '';
352
+ if (controller.signal.aborted) {
353
+ return terminal({ executionId, status: 'cancelled' });
281
354
  }
282
- const controller = new AbortController();
283
- activeControllers.set(executionId, controller);
284
- // `signal` doesn't need linking here — the listener registered above already reaches this
285
- // controller via `cancel()`'s `activeControllers` branch as soon as `activeControllers.set`
286
- // ran. A second direct link (this file used to have one, `linkAbortSignal`) was redundant for
287
- // this phase and, worse, gave the impression `signal` was fully handled when it was only wired
288
- // to the phase after the one that actually needed it — see the confirmation block above.
289
355
  const timeout = startTimeout(controller, descriptor.timeoutMs);
290
- // Cleanup (clear the timeout, drop the abort controller) is repeated at
356
+ // Cleanup (clear the timeout) is repeated at
291
357
  // each return below rather than centralized in a `finally` — a
292
358
  // try/finally here produced a synthetic "abrupt completion" branch
293
359
  // istanbul/v8 instruments for the finally block itself, which is
@@ -298,7 +364,6 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
298
364
  const cleanup = () => {
299
365
  if (timeout.handle)
300
366
  clearTimeout(timeout.handle);
301
- activeControllers.delete(executionId);
302
367
  };
303
368
  appendEvent(executionId, 'started');
304
369
  try {
@@ -311,30 +376,89 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
311
376
  run,
312
377
  input,
313
378
  signal: controller.signal,
314
- }, { ...(emitSurface !== undefined ? { emitSurface } : {}) });
315
- // A handler is not OBLIGED to honour `signal` — it is handed one, not policed by one. A
316
- // handler that ignores it and resolves normally after the timer fired (or after `cancel`)
317
- // used to land here and be recorded `completed`, because the timeout/abort state was only
318
- // ever consulted in the `catch`. That reports success for a call the contract has already
319
- // declared terminal in the other direction: the caller was told `timed-out`/`cancelled` is
320
- // final (see this module's state-machine doc), the audit trail said `completed`, and the two
321
- // disagreed. Whether the handler cooperated does not change what happened to the CALL.
322
- if (timeout.timedOut() || controller.signal.aborted) {
323
- cleanup();
324
- detachTransportAbort();
325
- return failureResult(executionId, new Error('handler resolved after the execution was already terminal'), timeout.timedOut(), controller.signal.aborted);
326
- }
327
- const { output, truncated } = truncateOutput(rawOutput, descriptor.maxOutputBytes);
328
- appendEvent(executionId, 'completed');
379
+ }, {
380
+ ...surfaceOption(emitSurface),
381
+ invokeTool: nestedInvoker({ principal, run, toolId: call.toolId, invocationRegistry: call.invocationRegistry, signal: controller.signal }, { emitSurface }),
382
+ });
329
383
  cleanup();
330
- detachTransportAbort();
331
- return { executionId, status: 'completed', output, truncated };
384
+ return settleOutput({ executionId, rawOutput, descriptor, timedOut: timeout.timedOut(), aborted: controller.signal.aborted }, { guidance, normalizationNotes });
332
385
  }
333
386
  catch (err) {
334
387
  cleanup();
388
+ return failureResult({ executionId, err, timedOut: timeout.timedOut(), aborted: controller.signal.aborted }, { normalizationNotes, guidance });
389
+ }
390
+ }
391
+ async function execute({ principal, run, toolId, input }, { signal, emitSurface, invocationRegistry = registry } = {}) {
392
+ // Existence is checked separately from — and before — authorization so the audit record can
393
+ // be opened before the (possibly slow, possibly throwing) authorization gate runs, matching
394
+ // pre-authorizeToolInvocation timing: `requested` used to precede `policy.authorize`, and a
395
+ // policy that hangs or throws used to still leave a record behind. `has()` only confirms
396
+ // existence, never returns a handler, so this doesn't reopen the leak authorizeToolInvocation
397
+ // closed — that gate still runs, still unconditionally, inside the call below.
398
+ assertKnownTool({ registry: invocationRegistry, toolId });
399
+ const executionId = openAudit(principal, run, toolId);
400
+ // Observes `signal` for this execution's ENTIRE remaining lifetime, not just the handler-run
401
+ // phase. `requestConfirmation()`'s wait below is unbounded — it settles only when a human
402
+ // answers or `resumeConfirmation`/`cancel` is called — so a transport disconnect or run-cancel
403
+ // that only reached a handler-phase `AbortController` (this file's previous design) had no way
404
+ // to end a still-parked confirmation; `execute()` simply never settled. Routed through the
405
+ // existing `cancel(executionId)` rather than a bespoke handler, so every trigger — an explicit
406
+ // `cancel()` call, a run cancellation, and now a transport disconnect — resolves a pending
407
+ // confirmation and an in-flight handler the exact same way.
408
+ if (signal?.aborted)
409
+ return terminal({ executionId, status: 'cancelled' });
410
+ const controller = new AbortController();
411
+ activeControllers.set(executionId, controller);
412
+ // `signal` doesn't need linking in `runHandler` — the listener registered here reaches this
413
+ // controller via `cancel()`'s `activeControllers` branch as soon as `activeControllers.set`
414
+ // ran. A second direct link (this file used to have one, `linkAbortSignal`) was redundant for
415
+ // that phase and, worse, gave the impression `signal` was fully handled when it was only wired
416
+ // to the phase after the one that actually needed it. Register here so cancellation also
417
+ // survives the microtask gap after confirmation settles and before `runHandler()` starts.
418
+ const detachTransportAbort = watchTransportAbort({ executionId, signal });
419
+ const cleanupCancellation = () => {
335
420
  detachTransportAbort();
336
- return failureResult(executionId, err, timeout.timedOut(), controller.signal.aborted);
421
+ activeControllers.delete(executionId);
422
+ };
423
+ // Both gates below can throw on INFRASTRUCTURE failure — a policy that rejects, a confirmation
424
+ // transport that drops. Those throws used to escape `execute` entirely, which cost two things:
425
+ // the audit record stayed open forever at `requested`/`authorized` with no terminal transition
426
+ // (the comment above only ever guarded against leaving NO record, not against leaving an
427
+ // unfinished one), and the raw exception text travelled outward to become tool-result content
428
+ // an agent reads — an authorization or transport internal surfaced to the model verbatim.
429
+ //
430
+ // Recorded terminal and returned as `failed` with a fixed message. `failed` is the right state:
431
+ // this execution ended, and it ended for an operational reason rather than a decision. The real
432
+ // exception stays server-side. Deliberately narrower than the `unknown tool` throw above, which
433
+ // stays a typed input-error throw because no tool execution exists to settle.
434
+ let resolved;
435
+ try {
436
+ resolved = await authorize({ principal, run, toolId, input, registry: invocationRegistry });
437
+ }
438
+ catch (error) {
439
+ cleanupCancellation();
440
+ return authorizationFailure({ executionId, error });
441
+ }
442
+ // `invocationRegistry.has({ toolId })` above already confirmed the tool exists, and `ToolRegistry` is
443
+ // append-only (no unregister — see `createToolRegistry`'s doc), so `authorizeToolInvocation`
444
+ // resolving the same `toolId` against the same `registry` cannot come back `undefined` here.
445
+ const gate = admitAuthorized({ executionId, resolved: resolved, signal: controller.signal });
446
+ if ('refusal' in gate) {
447
+ cleanupCancellation();
448
+ return gate.refusal;
449
+ }
450
+ const admitted = gate.allowed;
451
+ const { descriptor } = admitted;
452
+ if (descriptor.requiresConfirmation) {
453
+ const declined = await confirmGate({ executionId, principal, run, tool: descriptor, input: admitted.input });
454
+ if (declined) {
455
+ cleanupCancellation();
456
+ return declined;
457
+ }
337
458
  }
459
+ const result = await runHandler({ executionId, controller, principal, run, toolId, input: admitted.input, descriptor, handler: admitted.handler, normalizationNotes: admitted.normalizationNotes, invocationRegistry }, { emitSurface });
460
+ cleanupCancellation();
461
+ return result;
338
462
  }
339
463
  function resumeConfirmation({ executionId, decision }) {
340
464
  const resolve = pendingConfirmations.get(executionId);
@@ -348,8 +472,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
348
472
  const controller = activeControllers.get(executionId);
349
473
  if (controller) {
350
474
  controller.abort();
351
- return;
352
475
  }
476
+ // The controller also exists while confirmation is pending; aborting must still unblock it.
353
477
  const resolve = pendingConfirmations.get(executionId);
354
478
  if (resolve) {
355
479
  pendingConfirmations.delete(executionId);
@@ -362,4 +486,18 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
362
486
  }
363
487
  return { execute, resumeConfirmation, cancel, getAuditRecord };
364
488
  }
489
+ /** Preserve ordinary object fields while adding model-facing guidance to this tool result. */
490
+ function attachToolGuidance({ output, guidance }) {
491
+ if (!guidance)
492
+ return output;
493
+ if (typeof output === 'string')
494
+ return `${output}\n\n${guidance}`;
495
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
496
+ // MCP envelopes are projected by their content blocks; an extra JSON property may be ignored.
497
+ if ('content' in output && Array.isArray(output.content))
498
+ return { ...output, content: [...output.content, { type: 'text', text: guidance }] };
499
+ return { ...output, agentPluginGuidance: guidance };
500
+ }
501
+ return { result: output, agentPluginGuidance: guidance };
502
+ }
365
503
  //# sourceMappingURL=tool-executor.js.map