@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.
- package/CHANGELOG.md +8 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +31 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +1 -1
- package/dist/agent-executor/events.d.ts +1 -0
- package/dist/agent-executor/events.js +85 -74
- package/dist/agent-executor/launch.d.ts +4 -2
- package/dist/agent-executor/launch.js +306 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +26 -3
- package/dist/core/read-only-tools.js +38 -5
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-candidates.d.ts +8 -0
- package/dist/core/tool-candidates.js +6 -0
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-tool-bridge.d.ts +1 -1
- package/dist/delegated-tool-bridge.js +161 -122
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +5 -1
- package/dist/http/delegated-tools.js +29 -29
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +12 -2
- package/dist/run-lifecycle.js +47 -26
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +277 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- package/package.json +10 -10
package/dist/tool-executor.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
/**
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
212
|
+
const error = guidance ? `${message}\n\n${guidance}` : message;
|
|
213
|
+
return { executionId, status: 'failed', error, errorKind, ...(normalizationNotes.length ? { normalizationNotes } : {}) };
|
|
176
214
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 (
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
293
|
+
return await options.prepareGuidance({ principal, run, tool, signal,
|
|
294
|
+
...(emitSurface ? { emitSurface } : {}) }, {});
|
|
225
295
|
}
|
|
226
296
|
catch {
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
}, {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|