@jini-ai/daemon 0.6.8 → 0.6.10
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 +148 -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 +37 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +3 -1
- package/dist/agent-executor/events.d.ts +5 -0
- package/dist/agent-executor/events.js +143 -87
- package/dist/agent-executor/launch.d.ts +5 -3
- package/dist/agent-executor/launch.js +315 -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 +15 -0
- package/dist/core/read-only-tools.js +29 -1
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-result-correlation.d.ts +26 -0
- package/dist/delegated-result-correlation.js +68 -0
- package/dist/delegated-tool-bridge.d.ts +6 -14
- package/dist/delegated-tool-bridge.js +155 -155
- 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 +8 -1
- package/dist/http/delegated-tools.js +30 -30
- 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/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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 +31 -2
- package/dist/run-lifecycle.js +49 -42
- 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 +276 -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/dist/tool-result-diagnostics.d.ts +27 -0
- package/dist/tool-result-diagnostics.js +77 -0
- package/dist/tool-result-media.d.ts +27 -14
- package/dist/tool-result-media.js +51 -38
- package/package.json +14 -14
package/dist/tool-executor.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
/**
|
|
164
|
-
function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
212
|
+
const error = guidance ? `${message}\n\n${guidance}` : message;
|
|
213
|
+
return { executionId, status: 'failed', error, errorKind, ...(normalizationNotes.length ? { normalizationNotes } : {}) };
|
|
177
214
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 (
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
293
|
+
return await options.prepareGuidance({ principal, run, tool, signal,
|
|
294
|
+
...(emitSurface ? { emitSurface } : {}) }, {});
|
|
226
295
|
}
|
|
227
296
|
catch {
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
}, {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|