@jini-ai/daemon 0.6.8 → 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 (71) hide show
  1. package/CHANGELOG.md +4 -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 +15 -0
  18. package/dist/core/read-only-tools.js +29 -1
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-gaps.d.ts +60 -0
  22. package/dist/core/tool-gaps.js +199 -0
  23. package/dist/delegated-tool-bridge.d.ts +1 -1
  24. package/dist/delegated-tool-bridge.js +161 -122
  25. package/dist/event-log.d.ts +45 -2
  26. package/dist/event-log.js +83 -4
  27. package/dist/http/attachments.d.ts +26 -1
  28. package/dist/http/attachments.js +129 -65
  29. package/dist/http/connectors.d.ts +1 -32
  30. package/dist/http/connectors.js +13 -68
  31. package/dist/http/delegated-tools.d.ts +5 -1
  32. package/dist/http/delegated-tools.js +29 -29
  33. package/dist/http/host-tools.d.ts +1 -0
  34. package/dist/http/host-tools.js +64 -59
  35. package/dist/http/index.d.ts +2 -2
  36. package/dist/http/index.js +1 -1
  37. package/dist/http/media.js +48 -69
  38. package/dist/http/memory.js +45 -36
  39. package/dist/http/model-proxy-fields.d.ts +23 -0
  40. package/dist/http/model-proxy-fields.js +29 -0
  41. package/dist/http/model-proxy.js +193 -395
  42. package/dist/http/research-tavily.adapter.js +72 -49
  43. package/dist/http/research.js +33 -23
  44. package/dist/http/resource-request.d.ts +37 -0
  45. package/dist/http/resource-request.js +59 -0
  46. package/dist/http/routines.js +49 -90
  47. package/dist/http/runs.d.ts +7 -5
  48. package/dist/http/runs.js +21 -7
  49. package/dist/http/terminals.js +23 -48
  50. package/dist/http/tool-catalog.d.ts +5 -0
  51. package/dist/http/xai.d.ts +11 -1
  52. package/dist/http/xai.js +125 -124
  53. package/dist/legacy-data-migration.js +41 -24
  54. package/dist/run/core/retry.js +51 -35
  55. package/dist/run/diagnostics/diagnostics.js +5 -17
  56. package/dist/run-credentials.js +49 -27
  57. package/dist/run-lifecycle.d.ts +12 -2
  58. package/dist/run-lifecycle.js +47 -26
  59. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  60. package/dist/store/agent-sessions/sqlite.js +6 -4
  61. package/dist/surface-exchanges.js +2 -1
  62. package/dist/terminal-session.d.ts +1 -1
  63. package/dist/terminal-session.js +26 -33
  64. package/dist/tool-audit.d.ts +1 -0
  65. package/dist/tool-audit.js +1 -0
  66. package/dist/tool-executor.d.ts +24 -3
  67. package/dist/tool-executor.js +276 -139
  68. package/dist/tool-recovery/recovery.js +64 -35
  69. package/dist/tool-recovery/stack.d.ts +2 -1
  70. package/dist/tool-recovery/stack.js +1 -1
  71. package/package.json +10 -10
@@ -46,9 +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
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']);
52
61
  /**
53
62
  * Enforces `maxOutputBytes` in BYTES, which is what the option is named for.
54
63
  *
@@ -82,14 +91,18 @@ function truncateOutput(output, maxOutputBytes) {
82
91
  * — the one and only caller of that internal export.
83
92
  * @param options.delegate - Transport-supplied authorize/confirm UI seam;
84
93
  * omit for a headless caller whose tools never need interactive gating.
85
- * @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.
86
96
  * @overallScore 100/100
87
97
  */
88
98
  export function createToolExecutor(requiredArgs, optionalArgs = {}) {
89
99
  const options = { ...requiredArgs, ...optionalArgs };
90
100
  const { registry, delegate = {} } = options;
91
101
  const now = options.now ?? Date.now;
102
+ const maxAuditRecords = options.maxAuditRecords ?? DEFAULT_MAX_AUDIT_RECORDS;
92
103
  const audits = new Map();
104
+ // Finished executionIds, oldest first (Set insertion order): the only records `retireAudit` evicts.
105
+ const finishedAudits = new Set();
93
106
  const pendingConfirmations = new Map();
94
107
  const activeControllers = new Map();
95
108
  /**
@@ -108,6 +121,19 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
108
121
  // defensive `if (!record) return` guard with no reachable path to hit it.
109
122
  const record = audits.get(executionId);
110
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
+ }
111
137
  }
112
138
  function requestConfirmation(request) {
113
139
  if (delegate.onConfirm) {
@@ -133,7 +159,7 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
133
159
  appendEvent(executionId, 'requested');
134
160
  return executionId;
135
161
  }
136
- function authorize(principal, run, toolId, input) {
162
+ function authorize({ principal, run, toolId, input, registry }) {
137
163
  // `.bind(delegate)` rather than passing the bare method reference: `ExecutionDelegate` is
138
164
  // declared with method syntax, so a host may legitimately implement it as a class instance
139
165
  // whose `onAuthorize` reads `this`. Handing the detached function to `@jini-ai/core` would
@@ -160,135 +186,174 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
160
186
  handle.unref?.();
161
187
  return { handle, timedOut: () => timedOut };
162
188
  }
163
- /** Classifies a handler rejection into its terminal result, recording the matching transition. */
164
- function failureResult(executionId, err, timedOut, aborted) {
165
- if (timedOut) {
166
- appendEvent(executionId, 'timed-out');
167
- return { executionId, status: 'timed-out' };
168
- }
169
- if (aborted) {
170
- appendEvent(executionId, 'cancelled');
171
- return { executionId, status: 'cancelled' };
172
- }
173
- 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 });
174
210
  appendEvent(executionId, 'failed', message);
175
211
  const errorKind = err instanceof ToolInputError ? 'validation' : 'internal';
176
- 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 } : {}) };
177
214
  }
178
- async function execute({ principal, run, toolId, input }, { signal, emitSurface } = {}) {
179
- // Existence is checked separately from — and before — authorization so the audit record can
180
- // be opened before the (possibly slow, possibly throwing) authorization gate runs, matching
181
- // pre-authorizeToolInvocation timing: `requested` used to precede `policy.authorize`, and a
182
- // policy that hangs or throws used to still leave a record behind. `has()` only confirms
183
- // existence, never returns a handler, so this doesn't reopen the leak authorizeToolInvocation
184
- // closed — that gate still runs, still unconditionally, inside the call below.
185
- if (!registry.has({ toolId })) {
186
- const candidates = toolCandidates({ toolId, tools: registry.list({}) });
187
- const hint = candidates.length > 0 ? `; did you mean ${candidates.join(', ')}?` : '';
188
- 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);
189
272
  }
190
- const executionId = openAudit(principal, run, toolId);
191
- // Observes `signal` for this execution's ENTIRE remaining lifetime, not just the handler-run
192
- // phase. `requestConfirmation()`'s wait below is unbounded — it settles only when a human
193
- // answers or `resumeConfirmation`/`cancel` is called — so a transport disconnect or run-cancel
194
- // that only reached a handler-phase `AbortController` (this file's previous design) had no way
195
- // to end a still-parked confirmation; `execute()` simply never settled. Routed through the
196
- // existing `cancel(executionId)` rather than a bespoke handler, so every trigger — an explicit
197
- // `cancel()` call, a run cancellation, and now a transport disconnect — resolves a pending
198
- // confirmation and an in-flight handler the exact same way.
199
- let onTransportAbort;
200
- function detachTransportAbort() {
201
- if (onTransportAbort)
202
- signal?.removeEventListener('abort', onTransportAbort);
273
+ catch {
274
+ // Same reasoning as the authorization gate: terminal, recorded, and generic.
275
+ return operationalFailure({ executionId, error: 'confirmation failed' });
203
276
  }
204
- if (signal) {
205
- if (signal.aborted) {
206
- appendEvent(executionId, 'cancelled');
207
- return { executionId, status: 'cancelled' };
208
- }
209
- onTransportAbort = () => cancel({ executionId: executionId });
210
- signal.addEventListener('abort', onTransportAbort, { once: true });
277
+ if (confirmation === 'confirm') {
278
+ appendEvent(executionId, 'confirmed');
279
+ return undefined;
211
280
  }
212
- // Both gates below can throw on INFRASTRUCTURE failure — a policy that rejects, a confirmation
213
- // transport that drops. Those throws used to escape `execute` entirely, which cost two things:
214
- // the audit record stayed open forever at `requested`/`authorized` with no terminal transition
215
- // (the comment above only ever guarded against leaving NO record, not against leaving an
216
- // unfinished one), and the raw exception text travelled outward to become tool-result content
217
- // an agent reads — an authorization or transport internal surfaced to the model verbatim.
218
- //
219
- // Recorded terminal and returned as `failed` with a fixed message. `failed` is the right state:
220
- // this execution ended, and it ended for an operational reason rather than a decision. The real
221
- // exception stays server-side. Deliberately narrower than the `unknown tool` throw above, which
222
- // stays a typed input-error throw because no tool execution exists to settle.
223
- 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 }) {
224
292
  try {
225
- resolved = await authorize(principal, run, toolId, input);
293
+ return await options.prepareGuidance({ principal, run, tool, signal,
294
+ ...(emitSurface ? { emitSurface } : {}) }, {});
226
295
  }
227
296
  catch {
228
- detachTransportAbort();
229
- appendEvent(executionId, 'failed', 'authorization failed');
230
- return { executionId, status: 'failed', error: 'authorization failed' };
297
+ // Guidance availability must never convert an authorized write into a tool failure.
298
+ return '';
231
299
  }
232
- // `authorize()` isn't itself signal-aware — it's typically a fast policy check, not a
233
- // human-in-the-loop wait — so this is a post-await check rather than a mid-flight abort, the
234
- // same idiom the post-handler check below already uses: a signal that fired while `authorize()`
235
- // was in flight is caught here, before its (now-moot) decision is acted on.
236
- if (signal?.aborted) {
237
- detachTransportAbort();
238
- appendEvent(executionId, 'cancelled');
239
- return { executionId, status: 'cancelled' };
240
- }
241
- // `registry.has({ toolId })` above already confirmed the tool exists, and `ToolRegistry` is
242
- // append-only (no unregister — see `createToolRegistry`'s doc), so `authorizeToolInvocation`
243
- // resolving the same `toolId` against the same `registry` cannot come back `undefined` here.
244
- const { descriptor } = resolved;
245
- if (resolved.decision !== 'allow') {
246
- detachTransportAbort();
247
- appendEvent(executionId, 'denied');
248
- 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 });
249
337
  }
250
- appendEvent(executionId, 'authorized');
251
- const { handler } = resolved;
252
- // Kept inline rather than extracted into a helper: hoisting this into an `async` function adds
253
- // microtask ticks to the *un*-confirmed path too, and callers observe that timing — a test
254
- // cancels an in-flight execution after a fixed number of ticks and needs the handler to have
255
- // already started by then.
256
- if (descriptor.requiresConfirmation) {
257
- let confirmation;
258
- try {
259
- confirmation = await requestConfirmation({ executionId, principal, run, tool: descriptor, input });
260
- }
261
- catch {
262
- // Same reasoning as the authorization gate above: terminal, recorded, and generic.
263
- detachTransportAbort();
264
- appendEvent(executionId, 'failed', 'confirmation failed');
265
- return { executionId, status: 'failed', error: 'confirmation failed' };
266
- }
267
- if (confirmation !== 'confirm') {
268
- detachTransportAbort();
269
- // `cancel(executionId)` (called directly, or via the transport-abort listener above)
270
- // resolves a pending confirmation the same way a human `deny` does, but the two are not the
271
- // same OUTCOME: `confirmation-denied` asserts a decision was made, which is false when the
272
- // requester vanished before answering. `cancelledConfirmations` distinguishes them so the
273
- // audit trail never reports a decision nobody made.
274
- if (cancelledConfirmations.delete(executionId)) {
275
- appendEvent(executionId, 'cancelled');
276
- return { executionId, status: 'cancelled' };
277
- }
278
- appendEvent(executionId, 'confirmation-denied');
279
- return { executionId, status: 'confirmation-denied' };
280
- }
281
- 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' });
282
354
  }
283
- const controller = new AbortController();
284
- activeControllers.set(executionId, controller);
285
- // `signal` doesn't need linking here — the listener registered above already reaches this
286
- // controller via `cancel()`'s `activeControllers` branch as soon as `activeControllers.set`
287
- // ran. A second direct link (this file used to have one, `linkAbortSignal`) was redundant for
288
- // this phase and, worse, gave the impression `signal` was fully handled when it was only wired
289
- // to the phase after the one that actually needed it — see the confirmation block above.
290
355
  const timeout = startTimeout(controller, descriptor.timeoutMs);
291
- // Cleanup (clear the timeout, drop the abort controller) is repeated at
356
+ // Cleanup (clear the timeout) is repeated at
292
357
  // each return below rather than centralized in a `finally` — a
293
358
  // try/finally here produced a synthetic "abrupt completion" branch
294
359
  // istanbul/v8 instruments for the finally block itself, which is
@@ -299,7 +364,6 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
299
364
  const cleanup = () => {
300
365
  if (timeout.handle)
301
366
  clearTimeout(timeout.handle);
302
- activeControllers.delete(executionId);
303
367
  };
304
368
  appendEvent(executionId, 'started');
305
369
  try {
@@ -312,30 +376,89 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
312
376
  run,
313
377
  input,
314
378
  signal: controller.signal,
315
- }, { ...(emitSurface !== undefined ? { emitSurface } : {}) });
316
- // A handler is not OBLIGED to honour `signal` — it is handed one, not policed by one. A
317
- // handler that ignores it and resolves normally after the timer fired (or after `cancel`)
318
- // used to land here and be recorded `completed`, because the timeout/abort state was only
319
- // ever consulted in the `catch`. That reports success for a call the contract has already
320
- // declared terminal in the other direction: the caller was told `timed-out`/`cancelled` is
321
- // final (see this module's state-machine doc), the audit trail said `completed`, and the two
322
- // disagreed. Whether the handler cooperated does not change what happened to the CALL.
323
- if (timeout.timedOut() || controller.signal.aborted) {
324
- cleanup();
325
- detachTransportAbort();
326
- return failureResult(executionId, new Error('handler resolved after the execution was already terminal'), timeout.timedOut(), controller.signal.aborted);
327
- }
328
- const { output, truncated } = truncateOutput(rawOutput, descriptor.maxOutputBytes);
329
- appendEvent(executionId, 'completed');
379
+ }, {
380
+ ...surfaceOption(emitSurface),
381
+ invokeTool: nestedInvoker({ principal, run, toolId: call.toolId, invocationRegistry: call.invocationRegistry, signal: controller.signal }, { emitSurface }),
382
+ });
330
383
  cleanup();
331
- detachTransportAbort();
332
- return { executionId, status: 'completed', output, truncated };
384
+ return settleOutput({ executionId, rawOutput, descriptor, timedOut: timeout.timedOut(), aborted: controller.signal.aborted }, { guidance, normalizationNotes });
333
385
  }
334
386
  catch (err) {
335
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 = () => {
336
420
  detachTransportAbort();
337
- 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
+ }
338
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;
339
462
  }
340
463
  function resumeConfirmation({ executionId, decision }) {
341
464
  const resolve = pendingConfirmations.get(executionId);
@@ -349,8 +472,8 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
349
472
  const controller = activeControllers.get(executionId);
350
473
  if (controller) {
351
474
  controller.abort();
352
- return;
353
475
  }
476
+ // The controller also exists while confirmation is pending; aborting must still unblock it.
354
477
  const resolve = pendingConfirmations.get(executionId);
355
478
  if (resolve) {
356
479
  pendingConfirmations.delete(executionId);
@@ -363,4 +486,18 @@ export function createToolExecutor(requiredArgs, optionalArgs = {}) {
363
486
  }
364
487
  return { execute, resumeConfirmation, cancel, getAuditRecord };
365
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
+ }
366
503
  //# sourceMappingURL=tool-executor.js.map