@qilin-agent/tools 0.0.0-stage → 3.1.4

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.
@@ -0,0 +1,1341 @@
1
+ /**
2
+ * Tool registry, model presentation modes, and pre/guard/around/post/result
3
+ * execution pipeline.
4
+ * @module @qilin-agent/tools
5
+ */
6
+ import { Service } from '@qilin-agent/kylin';
7
+ import z from '@qilin-agent/schemastery';
8
+ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@qilin-agent/scope';
9
+ import { HarnessError } from '@qilin-agent/llm';
10
+ import { assertNever, deepFreeze, snapshotJsonValue } from '@qilin-agent/util-values';
11
+ import { assertSupportedJsonSchema, validateJsonSchemaValue } from "./json-schema.js";
12
+ import { createRunCodeTool, RUN_CODE_NAME } from "./ptc.js";
13
+ import { renderToolsSdk } from "./ts-types.js";
14
+ import { renderToolsSdkPy } from "./py-types.js";
15
+ /**
16
+ * Language → SDK-section renderer. The registry looks up the loaded
17
+ * `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk`
18
+ * section under a non-native mode; a runtime whose language is not a key
19
+ * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
20
+ * new backend language is three parallel edits — a {@link PtcSdkLanguage}
21
+ * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for
22
+ * its `run_code` schema strings — plus the renderer function this table points
23
+ * at. The `satisfies` clause pins this table's key set to that union, which
24
+ * the flavor table is checked against too, so any of the three left out is a
25
+ * typecheck failure. What no check reaches is the prose that names the values
26
+ * instead of deriving them: the seam's `qilin-ptc-runtime` README pair, its
27
+ * `PtcRuntime.language` JSDoc, and `docs/subsystems/ptc-runtime.md`
28
+ * with its zh pair, plus this package's own README pair and the
29
+ * {@link Config.mode} JSDoc.
30
+ */
31
+ /**
32
+ * The model-facing statement of the `ptc` collapse. Names the consequence
33
+ * (the call fails) and the route (inside the program), because a rule the
34
+ * model can only discover by being denied is one it corrects too late.
35
+ */
36
+ const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.`;
37
+ const SDK_RENDERERS = {
38
+ typescript: renderToolsSdk,
39
+ python: renderToolsSdkPy,
40
+ };
41
+ export { defineTool, valueSchemaSpecToJsonSchema, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, } from "./schema.js";
42
+ export { assertSupportedJsonSchema, assertObjectJsonSchema, validateJsonSchemaValue, JsonSchemaError, } from "./json-schema.js";
43
+ export { CodeRunFailedError, RUN_CODE_NAME } from "./ptc.js";
44
+ export { jsonSchemaToTs, renderToolsSdk } from "./ts-types.js";
45
+ export { jsonSchemaToPy, renderToolsSdkPy } from "./py-types.js";
46
+ export { defineContentToolFixture } from "./testing.js";
47
+ /**
48
+ * Scheduler entry point omitted from the generated named service API.
49
+ * The registry symbol is process-global (`Symbol.for`) because src-launch can
50
+ * load this package twice — the plugin entry through the profile resolution
51
+ * and bare-name imports through the built `lib` — and each copy mints its own
52
+ * `Symbol`, so a per-copy symbol makes the scheduler lookup miss.
53
+ * @internal
54
+ */
55
+ export const TOOL_RUNTIME_SCHEDULER = Symbol.for('@qilin-agent/tools.scheduler');
56
+ /** Canonical error code for cancellation after a tool body was invoked. */
57
+ export const TOOL_ABORTED = 'ABORTED';
58
+ /** Canonical error code for cancellation before a tool body was invoked. */
59
+ export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH';
60
+ /**
61
+ * Thrown (internally) when the model requests a tool that isn't registered.
62
+ * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
63
+ * failure is as routable as a tool-thrown one — retry/sandbox/replay code can
64
+ * distinguish it from a tool body's own error.
65
+ */
66
+ export class ToolNotFoundError extends HarnessError {
67
+ /**
68
+ * @param toolName - the name the caller asked for.
69
+ * @param reachableFrom - how the model reaches this tool instead, when the
70
+ * name IS visible and only the presentation denies calling it directly.
71
+ * Omitted for a name that is registered nowhere.
72
+ */
73
+ constructor(toolName, reachableFrom) {
74
+ super(reachableFrom === undefined
75
+ ? `unknown tool "${toolName}"`
76
+ : `unknown tool "${toolName}": ${reachableFrom}`, 'UNKNOWN_TOOL');
77
+ this.name = 'ToolNotFoundError';
78
+ }
79
+ }
80
+ /** Thrown when a tool body or post-policy value violates its declared output. */
81
+ export class ToolOutputError extends HarnessError {
82
+ /** Schema/value violations in validation order. */
83
+ violations;
84
+ constructor(toolName, violations) {
85
+ super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT');
86
+ this.name = 'ToolOutputError';
87
+ this.violations = violations;
88
+ }
89
+ }
90
+ /** Convert one projector exception into the canonical invalid-output failure. */
91
+ function projectionError(toolName, projector, error) {
92
+ return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`]);
93
+ }
94
+ /** Snapshot one projector result before later durable-result materialization. */
95
+ function snapshotProjection(toolName, projector, candidate) {
96
+ try {
97
+ const detached = snapshotJsonValue(candidate);
98
+ if (detached === undefined) {
99
+ throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]);
100
+ }
101
+ return detached;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof ToolOutputError)
105
+ throw error;
106
+ throw projectionError(toolName, projector, error);
107
+ }
108
+ }
109
+ /** Snapshot one body or policy value into the canonical invalid-output failure class. */
110
+ function snapshotToolValue(toolName, candidate) {
111
+ try {
112
+ const detached = snapshotJsonValue(candidate);
113
+ if (detached === undefined)
114
+ throw new ToolOutputError(toolName, ['value is not lossless JSON']);
115
+ return detached;
116
+ }
117
+ catch (error) {
118
+ if (error instanceof ToolOutputError)
119
+ throw error;
120
+ throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`]);
121
+ }
122
+ }
123
+ /**
124
+ * Best-effort human-readable message from an arbitrary thrown value: Error
125
+ * instances use `.message`; non-Error objects with a string `message`
126
+ * property (e.g. `throw { message: 'denied' }`) use it too; everything else
127
+ * is stringified.
128
+ */
129
+ function errorMessage(error) {
130
+ try {
131
+ if (error instanceof Error)
132
+ return error.message;
133
+ if (typeof error === 'object' && error !== null
134
+ && 'message' in error && typeof error.message === 'string') {
135
+ return error.message;
136
+ }
137
+ return String(error);
138
+ }
139
+ catch {
140
+ // A hostile thrown value can trap `instanceof`, property access, or string
141
+ // coercion. Error normalization is the outermost safety boundary, so its
142
+ // fallback must itself be total.
143
+ return '<unprintable thrown value>';
144
+ }
145
+ }
146
+ /** Derive one failure message from policy feedback without changing its rendered blocks. */
147
+ function failureMessageFromContent(content) {
148
+ const text = content
149
+ .map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
150
+ .join('\n');
151
+ return text.length > 0 ? text : 'tool result blocked by post-execute policy';
152
+ }
153
+ /** Snapshot and freeze one durable tool-result projection or reject lossy data. */
154
+ function materializePresentation(candidate) {
155
+ const detached = snapshotJsonValue(candidate);
156
+ if (detached === undefined) {
157
+ throw new TypeError('tool result must be losslessly JSON-serializable');
158
+ }
159
+ return deepFreeze(detached);
160
+ }
161
+ /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
162
+ function errorInfo(error) {
163
+ try {
164
+ return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined;
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ }
170
+ /** One scope's complete tool-registry contribution. */
171
+ class ToolLayer {
172
+ tools;
173
+ restrictions = new AnonymousEntries();
174
+ guards = new AnonymousEntries();
175
+ /**
176
+ * Presentation this scope's agent declared for itself, shadowing the
177
+ * deployment default. One cell rather than an entry table: two answers to
178
+ * "which form does the model see" is a contradiction, not a merge.
179
+ */
180
+ mode;
181
+ constructor(scope) {
182
+ this.tools = new NamedEntries(name => new Error(scope === undefined
183
+ ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
184
+ : `tool "${name}" is already registered in this scope`));
185
+ }
186
+ /** Whether every contribution table in this aggregate layer is empty. */
187
+ isEmpty() {
188
+ return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
189
+ && this.mode === undefined;
190
+ }
191
+ /** Whether every compiled restriction in this layer admits a global tool name. */
192
+ admits(name) {
193
+ for (const filter of this.restrictions.values()) {
194
+ if ((filter.allow !== undefined && !filter.allow.has(name))
195
+ || (filter.deny !== undefined && filter.deny.has(name)))
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+ /** First monotonic denial from this layer's live guard registrations. */
201
+ guardReason(exec) {
202
+ for (const guard of this.guards.values()) {
203
+ const reason = guard(exec);
204
+ if (reason !== undefined)
205
+ return reason;
206
+ }
207
+ return undefined;
208
+ }
209
+ }
210
+ /** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */
211
+ function resolveMaxParallelSubCalls(value) {
212
+ const maxParallelSubCalls = value ?? 10;
213
+ if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {
214
+ throw new Error('maxParallelSubCalls must be a positive integer');
215
+ }
216
+ return maxParallelSubCalls;
217
+ }
218
+ /**
219
+ * Tool registry and execution pipeline. Scoped registrations shadow globals;
220
+ * one visibility resolver feeds presentation, lookup, and dispatch.
221
+ */
222
+ export class ToolRuntime extends Service {
223
+ static inject = ['systemPrompt'];
224
+ static Config = z.object({
225
+ mode: z.union(['native', 'ptc']).default('native'),
226
+ maxParallelSubCalls: z.natural().min(1).default(10),
227
+ });
228
+ /** Internal staged view consumed by `qilin-agent-loop`'s parallel scheduler. */
229
+ [TOOL_RUNTIME_SCHEDULER] = {
230
+ prepare: exec => this.prepareScheduledExecution(exec),
231
+ dispatch: exec => this.dispatchScheduledExecution(exec),
232
+ finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
233
+ finish: (exec, result) => this.finishScheduledExecution(exec, result),
234
+ };
235
+ /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
236
+ deferredContexts = new WeakMap();
237
+ /** Executions whose tool body declared the current turn complete. */
238
+ concludingExecutions = new WeakSet();
239
+ /** Original caller cancellation, kept outside the wrapper-mutable execution object. */
240
+ cancellationStates = new WeakMap();
241
+ /** Definition-owned final content transform snapshotted before policy begins. */
242
+ contentFinalizers = new WeakMap();
243
+ layers = new ScopedLayers(scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change'); });
244
+ /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
245
+ defaultMode;
246
+ maxParallelSubCalls;
247
+ /**
248
+ * Reserved presentation transport, kept outside the filterable registration
249
+ * layers. Built on first need rather than at construction: which agents run
250
+ * a PTC mode is no longer known when the service is constructed, and the
251
+ * transport is stateless beyond its closures over `this`.
252
+ */
253
+ ptcTransport;
254
+ constructor(ctx, config = {}) {
255
+ super(ctx, 'tools');
256
+ // The schema already defaulted an omitted mode; the ?? narrows the
257
+ // optional-input type for direct (non-Loader) construction in tests.
258
+ this.defaultMode = config.mode ?? 'native';
259
+ this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls);
260
+ ctx.systemPrompt.tools(context => this.wireSchemas(context.scope));
261
+ if (this.defaultMode !== 'native') {
262
+ ctx.systemPrompt.section(this.collapseSection());
263
+ ctx.systemPrompt.section(this.sdkSection());
264
+ }
265
+ }
266
+ /**
267
+ * The prompt statement of the `ptc` executor collapse, registered wherever
268
+ * {@link sdkSection} is and rendering empty outside an effective `ptc`.
269
+ *
270
+ * Every tool contributes its own guidance section naming its tool, none of
271
+ * them qualify how that tool is reached, and they all render before the SDK.
272
+ * Without this the model reads a catalog of tools it is told to use and no
273
+ * statement that only `run_code` may be called, so it emits a native call,
274
+ * receives `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes
275
+ * the deployment is inconsistent. Its order places the rule before that
276
+ * guidance rather than after it.
277
+ *
278
+ * Native scopes render no instruction.
279
+ * @returns the section registration.
280
+ */
281
+ collapseSection() {
282
+ return {
283
+ name: 'tools:ptc-only',
284
+ order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'),
285
+ // The SAME predicate the executor denies by, so the prompt cannot state
286
+ // a rule the registry does not enforce (see `collapses`).
287
+ text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '',
288
+ };
289
+ }
290
+ /**
291
+ * The generated-SDK prompt section, registered globally by a PTC mode
292
+ * deployment and per scope by {@link presentAs}.
293
+ *
294
+ * The body regenerates from the CALLING scope, and renders empty for an
295
+ * agent presenting natively — an agent that opted out under a PTC mode
296
+ * deployment still sees the global registration, and an empty section is
297
+ * dropped from the rendered prompt.
298
+ * @returns the section registration.
299
+ */
300
+ sdkSection() {
301
+ return {
302
+ name: 'tools:sdk',
303
+ order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'),
304
+ interpolate: false,
305
+ // Regenerate from the calling scope's visible tools in stable order.
306
+ text: (context) => {
307
+ const mode = this.modeFor(context.scope);
308
+ if (mode === 'native')
309
+ return '';
310
+ const runtime = this.requirePtcRuntime(mode);
311
+ // Own-property read: a language like `toString`/`constructor` would
312
+ // otherwise resolve an inherited Object.prototype member as a renderer.
313
+ const render = SDK_RENDERERS[runtime.language];
314
+ /* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */
315
+ if (render === undefined)
316
+ throw new Error(`qilin-tools: no SDK renderer for ${runtime.language}`);
317
+ return render(this.sdkSchemas(context.scope));
318
+ },
319
+ };
320
+ }
321
+ /**
322
+ * The presentation one scope's agent sees: its own declaration, else the
323
+ * deployment default.
324
+ * @param scope - the calling agent, or undefined for the global view.
325
+ * @returns the resolved presentation mode.
326
+ */
327
+ modeFor(scope) {
328
+ // Nearest scope wins along the chain: a preset's standing declaration
329
+ // covers every agent parented under it, and an agent's own (were one ever
330
+ // declared) would override its preset's. The mode decides what the model
331
+ // SEES, which is exactly the class of fact the chain inherits.
332
+ const layers = this.layers.chainLayers(scope);
333
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
334
+ const mode = layers[index]?.mode;
335
+ if (mode !== undefined)
336
+ return mode;
337
+ }
338
+ return this.defaultMode;
339
+ }
340
+ /**
341
+ * The reserved `run_code` transport, built on first need.
342
+ *
343
+ * It never enters the global layer: per-agent restrictions must not remove
344
+ * it, and a scoped registration must not shadow it. The visibility resolver
345
+ * appends it after resolving the filterable global/scoped capability layers,
346
+ * and only for scopes whose mode actually presents it.
347
+ * @returns the shared transport definition.
348
+ */
349
+ requirePtcTransport() {
350
+ this.ptcTransport ??= createRunCodeTool(this, {
351
+ resolveWorkingDirectory: async (exec) => {
352
+ // Only Agent-owned PTC requires directory state; native registries
353
+ // and unowned programs can run without this service.
354
+ if (exec.agent === undefined)
355
+ return undefined;
356
+ const directories = this.ctx.get('workingDirectory');
357
+ if (directories === undefined)
358
+ throw new Error('qilin-tools: run_code with an Agent requires workingDirectory');
359
+ return directories.ensure(exec.agent, exec.signal);
360
+ },
361
+ requireRuntime: () => this.requirePtcRuntime(this.defaultMode),
362
+ peekApprover: () => this.ctx.get('approval'),
363
+ resolveSandboxPolicy: (exec) => {
364
+ const policy = this.ctx.get('sandboxPolicy');
365
+ if (policy === undefined)
366
+ throw new Error('qilin-tools: confined PTC runtime requires sandboxPolicy');
367
+ return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
368
+ },
369
+ // The language-aware description/parameters getters read the runtime
370
+ // without demanding one, so a native-default process can still project
371
+ // the transport for an agent that chose code.
372
+ peekRuntime: () => this.ctx.get('ptcRuntime'),
373
+ maxParallel: this.maxParallelSubCalls,
374
+ shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
375
+ });
376
+ return this.ptcTransport;
377
+ }
378
+ /**
379
+ * Present the calling scope's tools in `mode` instead of the deployment
380
+ * default. Nearest scope on the chain wins, so a preset's standing
381
+ * declaration covers every agent joined under it.
382
+ *
383
+ * Scoped only, and one declaration per scope: this is how an agent preset
384
+ * composes PTC mode agents beside native ones in the same process, and a
385
+ * process-global override would be the `mode` config field instead.
386
+ * @param mode - the presentation the covered agents' models see.
387
+ * @returns the exact disposer that restores the deployment default.
388
+ */
389
+ presentAs(mode) {
390
+ const ctx = this.ctx;
391
+ if (scopeOf(ctx) === undefined) {
392
+ throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row');
393
+ }
394
+ const dispose = ctx.effect(function* () {
395
+ yield this.layers.effect(ctx, (layer) => {
396
+ if (layer.mode !== undefined) {
397
+ throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`);
398
+ }
399
+ layer.mode = mode;
400
+ return () => { layer.mode = undefined; };
401
+ }, { label: 'tools.presentAs()' });
402
+ // The SDK and collapse sections are per scope for the same reason the
403
+ // mode is. Under a deployment that already defaults to PTC mode this
404
+ // shadows the global registration with an identical body, which costs
405
+ // nothing and keeps one rule instead of a case analysis.
406
+ if (mode !== 'native') {
407
+ yield ctx.systemPrompt.section(this.collapseSection());
408
+ yield ctx.systemPrompt.section(this.sdkSection());
409
+ }
410
+ }.bind(this), 'tools.presentAs()');
411
+ // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown
412
+ return dispose;
413
+ }
414
+ /**
415
+ * Build one scope's wire schemas and names for prompt-order validation.
416
+ * Restrictions do not make known tools invalid, but a mode collapse does.
417
+ */
418
+ wireSchemas(scope) {
419
+ const view = this.view(scope);
420
+ const mode = this.modeFor(scope);
421
+ if (mode === 'native') {
422
+ const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false));
423
+ return { schemas, knownNames: [...view.knownNames] };
424
+ }
425
+ // Validate the runtime language BEFORE projecting schemas: schemaOf reads
426
+ // run_code's language-aware description/parameters getters, whose own
427
+ // flavor-table guard would otherwise surface first. This keeps the
428
+ // renderer-table rejection the canonical assembly-time error for a
429
+ // language with no SDK renderer.
430
+ this.requirePtcRuntime(mode);
431
+ const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false));
432
+ return {
433
+ schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
434
+ knownNames: [RUN_CODE_NAME],
435
+ };
436
+ }
437
+ /**
438
+ * Resolve the PTC runtime or throw the actionable misconfiguration error.
439
+ * Read at use time (assembly / run_code execution), NOT via static
440
+ * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
441
+ * behind it — hostage to a PTC runtime existing even under `mode:
442
+ * 'native'`.
443
+ *
444
+ * Assembly and `run_code` execution read separately, so the language is not
445
+ * bound to a request. Harmless while one published backend exists — both
446
+ * reads return the same flavor — but a reload that swapped in a second
447
+ * language between them would hand a program written against one SDK to the
448
+ * other. Binding it is deferred until a second backend ships (the first
449
+ * point it is testable).
450
+ */
451
+ requirePtcRuntime(mode) {
452
+ const runtime = this.ctx.get('ptcRuntime');
453
+ if (!runtime) {
454
+ throw new Error(`qilin-tools: mode "${mode}" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @qilin-agent/ptc-runtime-node) or set tools mode to "native"`);
455
+ }
456
+ if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
457
+ const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ');
458
+ throw new Error(`qilin-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`);
459
+ }
460
+ return runtime;
461
+ }
462
+ /**
463
+ * Register globally or in the calling agent scope. Scoped tools shadow
464
+ * globals; duplicates within one layer and the reserved `run_code` name fail.
465
+ * @param definition - tool schema, execution, and optional finalization/presentation callbacks.
466
+ * @returns the exact disposer that unregisters the tool.
467
+ */
468
+ register(definition) {
469
+ const name = definition.name;
470
+ const output = definition.output;
471
+ if (output === undefined || typeof output !== 'object'
472
+ || typeof output.render !== 'function'
473
+ || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
474
+ throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`);
475
+ }
476
+ assertSupportedJsonSchema(output.schema);
477
+ const timeoutMs = definition.timeoutMs;
478
+ if (timeoutMs !== undefined
479
+ && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
480
+ throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`);
481
+ }
482
+ // Reserved unconditionally: any agent may select a code mode for itself,
483
+ // so a name free to take under the deployment default would become a
484
+ // collision the moment a preset mounted.
485
+ if (name === RUN_CODE_NAME) {
486
+ throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the PTC mode presentation transport and cannot be registered or shadowed`);
487
+ }
488
+ return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition), { label: 'tools.register()' });
489
+ }
490
+ /**
491
+ * Restrict global tools for the calling agent scope. Empty filters, unknown
492
+ * names, scope-local names, and reserved transport names fail. Restrictions
493
+ * intersect; scoped registrations remain visible.
494
+ * @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
495
+ * @returns the exact disposer that lifts this restriction.
496
+ */
497
+ restrict(filter) {
498
+ const scope = scopeOf(this.ctx);
499
+ if (scope === undefined) {
500
+ throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead');
501
+ }
502
+ const allow = filter.allow;
503
+ const deny = filter.deny;
504
+ if (allow === undefined && deny === undefined) {
505
+ throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)');
506
+ }
507
+ const compiled = {
508
+ ...allow !== undefined ? { allow: new Set(allow) } : {},
509
+ ...deny !== undefined ? { deny: new Set(deny) } : {},
510
+ };
511
+ if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
512
+ throw new Error(`tools.restrict() cannot name reserved PTC mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`);
513
+ }
514
+ const known = this.view(scope).restrictableNames;
515
+ const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name));
516
+ if (unknown.length > 0) {
517
+ throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`);
518
+ }
519
+ return this.layers.effect(this.ctx, layer => layer.restrictions.append(compiled), { label: 'tools.restrict()' });
520
+ }
521
+ /**
522
+ * Register a monotonic guard after the extensible `tools/pre-execute`
523
+ * waterfall. A plain-context guard applies globally; one registered through
524
+ * `agent.ctx` applies only to that agent. Any matching guard may deny by
525
+ * returning a reason, while no guard can force-allow a call another guard
526
+ * denied. The exact effect disposer is returned for ordered ownership and
527
+ * HMR cleanup.
528
+ * @param guard - synchronous check; a returned string denies the execution.
529
+ * @returns the exact disposer that unregisters the guard.
530
+ */
531
+ guard(guard) {
532
+ return this.layers.effect(this.ctx, layer => layer.guards.append(guard), { label: 'tools.guard()', notify: false });
533
+ }
534
+ /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
535
+ guardReason(exec) {
536
+ const globalReason = this.layers.global.guardReason(exec);
537
+ if (globalReason !== undefined)
538
+ return globalReason;
539
+ if (exec.agent === undefined)
540
+ return undefined;
541
+ for (const layer of this.layers.chainLayers(exec.agent)) {
542
+ const reason = layer.guardReason(exec);
543
+ if (reason !== undefined)
544
+ return reason;
545
+ }
546
+ return undefined;
547
+ }
548
+ /**
549
+ * Resolve every registry fact one scope needs in one layer traversal. The
550
+ * visible map applies restrictions to the INHERITED surface, then the
551
+ * scope's own registrations and the reserved presentation transport; the
552
+ * other sets retain the pre-restriction facts needed by restriction and
553
+ * prompt-order validation.
554
+ *
555
+ * A restriction filters what a scope inherits — the global layer and every
556
+ * ancestor layer on its chain — and never what its OWN layer registers.
557
+ * That exemption is what a per-child capability filter has to keep intact:
558
+ * the delegation runtime registers a child's structured-output tool into the
559
+ * child's own layer, and a filter naming the capabilities the child may use
560
+ * must not strip the machinery it answers through.
561
+ *
562
+ * Reading the exempt set as "the global layer" instead of "not mine" held
563
+ * only while every model-facing tool sat in the host composition. Once
564
+ * presets moved them onto the agent plane they became an ANCESTOR
565
+ * contribution, so a child's filter silently stopped constraining anything
566
+ * it was given.
567
+ * @param scope - the viewing scope (the agent), or undefined for the global view.
568
+ * @returns the complete derived view for that scope.
569
+ */
570
+ view(scope) {
571
+ // Scope-chain layers, farthest ancestor first, the exact scope last.
572
+ const layers = this.layers.chainLayers(scope);
573
+ // Chain-blind on purpose: this is the ONE layer whose registrations the
574
+ // scope owns rather than inherits, and it is absent until the scope
575
+ // contributes something.
576
+ const own = this.layers.peek(scope);
577
+ // Inherited surface, nearest ancestor last: a nearer scope's same-name
578
+ // entry shadows a farther one, and the global layer is the farthest.
579
+ const inherited = new Map(this.layers.global.tools.entries());
580
+ for (const layer of layers) {
581
+ if (layer === own)
582
+ continue;
583
+ for (const [name, definition] of layer.tools.entries())
584
+ inherited.set(name, definition);
585
+ }
586
+ const visible = new Map();
587
+ const knownNames = new Set();
588
+ const restrictableNames = new Set();
589
+ for (const [name, definition] of inherited) {
590
+ knownNames.add(name);
591
+ restrictableNames.add(name);
592
+ // Restrictions intersect across the whole chain: any scope on it may
593
+ // mask an inherited name for everything nested inside it.
594
+ if (layers.every(layer => layer.admits(name)))
595
+ visible.set(name, definition);
596
+ }
597
+ // The scope's own registrations last, shadowing an inherited name and
598
+ // outside the filter above.
599
+ if (own !== undefined) {
600
+ for (const [name, definition] of own.tools.entries()) {
601
+ knownNames.add(name);
602
+ visible.set(name, definition);
603
+ }
604
+ }
605
+ // Presentation infrastructure is resolved last and outside capability
606
+ // filtering. Registration rejects this reserved name, so the insertion is
607
+ // an invariant assertion as well as protection against future layer
608
+ // changes. Per scope: a native agent must not find `run_code` in its
609
+ // dispatch table because some other agent in the process presents it.
610
+ if (this.modeFor(scope) !== 'native') {
611
+ visible.set(RUN_CODE_NAME, this.requirePtcTransport());
612
+ }
613
+ return { visible, knownNames, restrictableNames };
614
+ }
615
+ /**
616
+ * Look up a tool as one scope sees it (scoped
617
+ * shadows global; a restricted-away global reads as absent). Presenters pass
618
+ * the calling agent so the rendered card matches the definition that
619
+ * actually executed.
620
+ * @param name - the tool name as registered.
621
+ * @param scope - the viewing scope (the agent); omitted = the global view.
622
+ * @returns the definition the scope resolves, or undefined when none is visible.
623
+ */
624
+ get(name, scope) {
625
+ return this.view(scope).visible.get(name);
626
+ }
627
+ /**
628
+ * Resolve the definition that MAY EXECUTE for a call, applying the mode
629
+ * collapse at the operation boundary that owns it. The registry view
630
+ * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc`
631
+ * may only name the reserved `run_code` transport, while a nested
632
+ * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool
633
+ * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL`
634
+ * through the executor, matching an absent definition.
635
+ * @param name - the tool name as registered.
636
+ * @param scope - the viewing scope (the agent); omitted = the global view.
637
+ * @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
638
+ * @returns the definition that may run, or undefined when the call must be rejected.
639
+ */
640
+ resolveExecution(name, scope, nested) {
641
+ const tool = this.get(name, scope);
642
+ if (tool === undefined)
643
+ return undefined;
644
+ if (this.collapses(name, scope, nested))
645
+ return undefined;
646
+ return tool;
647
+ }
648
+ /**
649
+ * Project visible definitions onto the allowlisted model-facing schema fields,
650
+ * excluding execution and presentation callbacks.
651
+ * @param scope - the viewing scope (the agent); omitted = the global view.
652
+ * @returns one deep-cloned schema per visible tool.
653
+ */
654
+ schemas(scope) {
655
+ return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true));
656
+ }
657
+ /** Project visible callable tools onto the generated PTC mode SDK contract. */
658
+ sdkSchemas(scope) {
659
+ return [...this.view(scope).visible.values()]
660
+ .filter(definition => definition.name !== RUN_CODE_NAME)
661
+ .map((definition) => {
662
+ const output = snapshotJsonValue(definition.output.schema);
663
+ /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
664
+ if (output === undefined) {
665
+ throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`);
666
+ }
667
+ return {
668
+ ...this.schemaOf(definition, true),
669
+ output,
670
+ };
671
+ });
672
+ }
673
+ /** Project one definition onto the model-facing schema fields. */
674
+ schemaOf(definition, detachParameters) {
675
+ const { name, description, parameters, deferLoading } = definition;
676
+ const detached = detachParameters ? snapshotJsonValue(parameters) : parameters;
677
+ if (detached === undefined) {
678
+ throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`);
679
+ }
680
+ return {
681
+ name,
682
+ description,
683
+ parameters: detached,
684
+ ...deferLoading === true ? { deferLoading } : {},
685
+ };
686
+ }
687
+ /**
688
+ * Classify a pending call through the caller's visible tool definition. Only
689
+ * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
690
+ * throwing classifiers are exclusive.
691
+ * @param exec - call name, parsed arguments, and optional agent scope.
692
+ * @returns the fail-closed scheduling mode.
693
+ */
694
+ executionMode(exec) {
695
+ const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
696
+ if (!tool?.isConcurrencySafe)
697
+ return { kind: 'exclusive' };
698
+ try {
699
+ const concurrencySafe = tool.isConcurrencySafe(exec.arguments);
700
+ return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' };
701
+ }
702
+ catch {
703
+ return { kind: 'exclusive' };
704
+ }
705
+ }
706
+ /**
707
+ * Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch
708
+ * and return the content the bridge should log on `tool/ptc-dispatch`.
709
+ * Contained: when a listener throws, the method logs the original settled
710
+ * content; that failure must not fail the dispatch or omit the settle event. Private:
711
+ * the ONE consumer is the `run_code` bridge this registry constructs, which
712
+ * receives it as a capability parameter (the `requireRuntime` idiom) — the
713
+ * waterfall, not this invoker, is the public extension point.
714
+ */
715
+ async shapeDispatchLog(dispatch) {
716
+ try {
717
+ return await this.ctx.waterfall(scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, () => Promise.resolve(dispatch.content));
718
+ }
719
+ catch (error) {
720
+ this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`);
721
+ return dispatch.content;
722
+ }
723
+ }
724
+ /**
725
+ * Whether the `ptc` mode collapse denies a model-direct call: only the
726
+ * reserved `run_code` transport may be named. Nested sub-dispatches (a
727
+ * `parent` token set) bypass the collapse. One home for the
728
+ * security-relevant predicate, shared by {@link resolveExecution} and
729
+ * {@link createExecution} so the two can never drift apart.
730
+ *
731
+ * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc`
732
+ * by an agent preset under a native deployment is the composition
733
+ * `qilin-agent-tool-presentation` exists for, and reading the deployment default would
734
+ * leave exactly that agent uncollapsed — announcing one surface while
735
+ * executing another, which is the bypass this collapse closes.
736
+ * @param name - the tool name as registered.
737
+ * @param scope - the viewing scope whose effective presentation mode applies.
738
+ * @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
739
+ */
740
+ collapses(name, scope, nested) {
741
+ return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME;
742
+ }
743
+ /**
744
+ * Execute through pre-policy, guards, around-dispatch, post-policy,
745
+ * definition-owned content finalization, and final notification. Tool and
746
+ * listener failures resolve as materialized error results; an invisible tool
747
+ * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
748
+ * snapshot final observers receive. Cancellation
749
+ * arriving after entry and before final result materialization skips a
750
+ * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
751
+ * successful started outcome with `ABORTED`; already-started work is still
752
+ * drained and may retain a tool-owned structured error.
753
+ * @param exec - the typed same-process call input. The registry assigns its
754
+ * correlation token before policy begins.
755
+ * @returns the materialized final result.
756
+ */
757
+ async execute(exec) {
758
+ return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared));
759
+ }
760
+ async completeScheduledExecution(prepared) {
761
+ switch (prepared.kind) {
762
+ case 'dispatch': {
763
+ const dispatched = await this.dispatchScheduledExecution(prepared.exec);
764
+ return dispatched.kind === 'post-result'
765
+ ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
766
+ : this.finishScheduledExecution(prepared.exec, dispatched.result);
767
+ }
768
+ case 'post-result':
769
+ return await this.finalizeScheduledExecution(prepared.exec, prepared.result);
770
+ case 'final-result':
771
+ return this.finishScheduledExecution(prepared.exec, prepared.result);
772
+ /* v8 ignore next -- closed-union exhaustiveness guard */
773
+ default:
774
+ return assertNever(prepared, 'scheduled tool preparation');
775
+ }
776
+ }
777
+ createExecution(exec) {
778
+ const deferredContexts = [];
779
+ const token = createExecutionToken();
780
+ const callId = exec.callId;
781
+ const rootCallId = exec.rootCallId ?? callId;
782
+ const name = exec.name;
783
+ const agent = exec.agent;
784
+ const parent = exec.parent;
785
+ const signal = exec.signal;
786
+ // Distinguish a mode-collapsed call (visible in the scope, denied only by
787
+ // the `ptc` collapse) from a genuinely unknown tool. A collapsed call is
788
+ // deterministically denied, so it terminates BEFORE the extensible policy
789
+ // pipeline: pre-execute listeners, approval `ask`, and guards must never
790
+ // observe — or worse, approve — a call that can only fail. An unknown tool
791
+ // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy
792
+ // listeners still see every name that reaches the registry.
793
+ const visible = this.get(name, agent);
794
+ const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined);
795
+ const concludingExecutions = this.concludingExecutions;
796
+ const base = {
797
+ token,
798
+ callId,
799
+ rootCallId,
800
+ name,
801
+ signal,
802
+ ...agent !== undefined ? { agent } : {},
803
+ ...parent !== undefined ? { parent } : {},
804
+ ...exec.schema !== undefined ? { schema: exec.schema } : {},
805
+ deferContext(context) {
806
+ deferredContexts.push(context);
807
+ },
808
+ concludeTurn() {
809
+ concludingExecutions.add(this);
810
+ },
811
+ };
812
+ // Capture the finalizer BEFORE argument materialization: the
813
+ // `finalizeContent` contract snapshots the callback when the call starts,
814
+ // and an arguments getter can replace or clear the registered callback
815
+ // during `snapshotJsonValue`. The collapse only decides whether the
816
+ // CAPTURED callback is retained: the pre-dispatch abort path keeps it
817
+ // (the cancellation contract routes aborted results through it — a getter
818
+ // that aborts mid-materialization before an invalid-args failure lands in
819
+ // the same retained path), while the `UNKNOWN_TOOL` denial and the
820
+ // invalid-args failure of a NON-ABORTED collapsed call drop it (the call
821
+ // could never execute).
822
+ const capturedFinalizer = visible?.finalizeContent?.bind(visible);
823
+ const finalizerFor = () => collapsed && !signal.aborted ? undefined : capturedFinalizer;
824
+ try {
825
+ const detached = snapshotJsonValue(exec.arguments);
826
+ if (detached === undefined) {
827
+ throw new TypeError('tool execution arguments must be losslessly JSON-serializable');
828
+ }
829
+ const execution = { ...base, arguments: deepFreeze(detached) };
830
+ this.deferredContexts.set(execution, deferredContexts);
831
+ this.contentFinalizers.set(execution, finalizerFor());
832
+ this.cancellationStates.set(execution, {
833
+ callerSignal: signal,
834
+ bodyInvoked: false,
835
+ });
836
+ if (collapsed) {
837
+ // The collapse denies the call before the policy pipeline, but a
838
+ // pre-dispatch abort still keeps the established cancellation
839
+ // contract: `prepare`'s caller-cancellation check is skipped for
840
+ // final-results, so honor the abort here instead of surfacing
841
+ // `UNKNOWN_TOOL` on an already-cancelled call.
842
+ if (signal.aborted) {
843
+ return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() };
844
+ }
845
+ // The name IS visible here, so the denial carries the route the model
846
+ // must take instead. Without it the model reads a bare `unknown tool`
847
+ // for a tool the prompt just declared and concludes the deployment is
848
+ // broken rather than correcting itself.
849
+ return {
850
+ kind: 'final-result',
851
+ exec: execution,
852
+ result: toolErrorResult(new ToolNotFoundError(name, `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`)),
853
+ };
854
+ }
855
+ return { kind: 'ready', exec: execution };
856
+ }
857
+ catch (error) {
858
+ const execution = { ...base, arguments: undefined };
859
+ this.contentFinalizers.set(execution, finalizerFor());
860
+ return { kind: 'final-result', exec: execution, result: toolErrorResult(error) };
861
+ }
862
+ }
863
+ /**
864
+ * Run the ordered pre-execute and monotonic guard stages for the scheduler.
865
+ * @param input - the caller-supplied execution input.
866
+ * @returns the prepared execution plus the next scheduler stage.
867
+ * @internal
868
+ */
869
+ async prepareScheduledExecution(input) {
870
+ return this.prepareExecution(input, prepared => prepared);
871
+ }
872
+ async prepareExecution(input, next) {
873
+ const created = this.createExecution(input);
874
+ if (created.kind !== 'ready')
875
+ return next(created);
876
+ const exec = created.exec;
877
+ if (this.callerCancelled(exec)) {
878
+ return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() });
879
+ }
880
+ try {
881
+ const carrier = scopeTarget(this, exec.agent);
882
+ const gate = await this.ctx.waterfall(carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }));
883
+ const askResolution = gate.kind === 'ask'
884
+ ? await this.serviceAsk(exec, gate)
885
+ : { decision: gate, approvalCancelled: false };
886
+ const { decision } = askResolution;
887
+ if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
888
+ return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
889
+ }
890
+ if (decision.kind === 'cancel') {
891
+ return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
892
+ }
893
+ const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason;
894
+ const denialInfo = decision.kind === 'deny' ? decision.info : undefined;
895
+ if (denialReason !== undefined) {
896
+ return await next({
897
+ kind: 'post-result',
898
+ exec,
899
+ result: this.materializeFinalResult({
900
+ content: [{ type: 'text', text: `Error: ${denialReason}` }],
901
+ isError: true,
902
+ error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } },
903
+ }),
904
+ });
905
+ }
906
+ if (this.callerCancelled(exec)) {
907
+ return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
908
+ }
909
+ return await next({ kind: 'dispatch', exec });
910
+ }
911
+ catch (error) {
912
+ return next({ kind: 'final-result', exec, result: toolErrorResult(error) });
913
+ }
914
+ }
915
+ /** Whether the original caller signal is currently aborted. */
916
+ callerCancelled(exec) {
917
+ const state = this.cancellationStates.get(exec);
918
+ /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
919
+ if (state === undefined)
920
+ throw new Error('tool registry scheduler invariant violated: missing cancellation state');
921
+ return state.callerSignal.aborted;
922
+ }
923
+ /** Canonical cancellation outcome selected by whether the tool body started. */
924
+ cancellationResult(exec, prior) {
925
+ const state = this.cancellationStates.get(exec);
926
+ /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
927
+ if (state === undefined)
928
+ throw new Error('tool registry scheduler invariant violated: missing cancellation state');
929
+ return state.bodyInvoked
930
+ ? toolAbortedResult(prior)
931
+ : toolAbortedBeforeDispatchResult(prior);
932
+ }
933
+ /**
934
+ * Dispatch the registered body with the original caller signal fused back
935
+ * into any around-wrapper replacement. Cancellation never abandons the body:
936
+ * a started promise reaches quiescence before its outcome becomes `ABORTED`.
937
+ */
938
+ async dispatchToolBody(exec) {
939
+ const state = this.cancellationStates.get(exec);
940
+ /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
941
+ if (state === undefined)
942
+ throw new Error('tool registry scheduler invariant violated: missing cancellation state');
943
+ const wrapperSignal = exec.signal;
944
+ const fused = fuseToolSignals(state.callerSignal, wrapperSignal);
945
+ const signal = fused.signal;
946
+ if (isAborted(signal)) {
947
+ fused.dispose();
948
+ return toolAbortedBeforeDispatchResult();
949
+ }
950
+ exec.signal = signal;
951
+ try {
952
+ const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
953
+ if (!tool)
954
+ throw new ToolNotFoundError(exec.name);
955
+ state.bodyInvoked = true;
956
+ const returned = await tool.execute(exec.arguments, exec);
957
+ const result = this.createSuccessResult(exec, tool, returned);
958
+ return isAborted(signal)
959
+ ? toolAbortedResult(result)
960
+ : result;
961
+ }
962
+ catch (error) {
963
+ return toolErrorResult(error);
964
+ }
965
+ finally {
966
+ fused.dispose();
967
+ exec.signal = wrapperSignal;
968
+ }
969
+ }
970
+ /**
971
+ * Run around-dispatch and the tool body. Tool and unknown-tool failures still
972
+ * receive post-execute; pipeline failures are already final.
973
+ * @param exec - the prepared execution.
974
+ * @returns whether the result still needs post-execute.
975
+ * @internal
976
+ */
977
+ async dispatchScheduledExecution(exec) {
978
+ try {
979
+ const mutableExec = exec;
980
+ const carrier = scopeTarget(this, exec.agent);
981
+ const result = await this.ctx.waterfall(carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec));
982
+ const normalized = this.normalizeDispatchResult(exec, result);
983
+ const deferredContexts = this.deferredContexts.get(exec);
984
+ /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
985
+ if (deferredContexts === undefined)
986
+ throw new Error('tool registry scheduler invariant violated: unprepared execution');
987
+ const resultWithDeferredContexts = deferredContexts.length === 0
988
+ ? normalized
989
+ : this.markCanonical(exec, {
990
+ ...normalized,
991
+ additionalContexts: [
992
+ ...deferredContexts,
993
+ ...normalized.additionalContexts ?? [],
994
+ ],
995
+ });
996
+ return {
997
+ kind: 'post-result',
998
+ result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
999
+ ? this.cancellationResult(exec, resultWithDeferredContexts)
1000
+ : resultWithDeferredContexts,
1001
+ };
1002
+ }
1003
+ catch (error) {
1004
+ return { kind: 'final-result', result: toolErrorResult(error) };
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Run ordered post-execute, then apply definition-owned content finalization,
1009
+ * materialize, and notify the final outcome.
1010
+ * @param exec - the prepared execution.
1011
+ * @param result - dispatch/pre result that still needs post-execute.
1012
+ * @returns the materialized final result.
1013
+ * @internal
1014
+ */
1015
+ async finalizeScheduledExecution(exec, result) {
1016
+ try {
1017
+ const postResult = await this.postExecute(exec, result);
1018
+ return this.finishScheduledExecution(exec, this.callerCancelled(exec) && !postResult.isError
1019
+ ? this.cancellationResult(exec, postResult)
1020
+ : postResult);
1021
+ }
1022
+ catch (error) {
1023
+ return this.finishScheduledExecution(exec, toolErrorResult(error));
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Materialize the candidate, apply definition-owned content finalization,
1028
+ * then materialize and notify the authoritative result.
1029
+ * @param exec - the prepared execution.
1030
+ * @param result - final result.
1031
+ * @returns the materialized final result.
1032
+ * @internal
1033
+ */
1034
+ finishScheduledExecution(exec, result) {
1035
+ let materializedResult;
1036
+ try {
1037
+ materializedResult = this.materializeFinalResult(result);
1038
+ }
1039
+ catch (error) {
1040
+ materializedResult = this.materializeFinalResult(toolErrorResult(error));
1041
+ }
1042
+ let finalResult;
1043
+ try {
1044
+ finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult));
1045
+ }
1046
+ catch (error) {
1047
+ finalResult = this.materializeFinalResult(toolErrorResult(error));
1048
+ }
1049
+ this.notifyResult(exec, finalResult);
1050
+ return finalResult;
1051
+ }
1052
+ /** Apply the snapshotted tool-owned content transform without exposing other result fields. */
1053
+ applyFinalContent(exec, result) {
1054
+ const finalizeContent = this.contentFinalizers.get(exec);
1055
+ if (finalizeContent === undefined)
1056
+ return result;
1057
+ const content = finalizeContent(exec, result);
1058
+ return content === undefined ? result : { ...result, content };
1059
+ }
1060
+ /** Notify observers without exposing a mutation or error channel into the outcome. */
1061
+ notifyResult(exec, result) {
1062
+ // Freeze the registry's live object before observers receive its readonly
1063
+ // WeakMap-keyable view.
1064
+ Object.freeze(exec);
1065
+ const { name: toolName, callId } = exec;
1066
+ const reportFailure = (error) => {
1067
+ this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`);
1068
+ };
1069
+ const callbacks = this.ctx.events.dispatch('emit', [
1070
+ scopeTarget(this, exec.agent), 'tools/result', exec, result,
1071
+ ]);
1072
+ for (const callback of callbacks) {
1073
+ try {
1074
+ const returned = callback(exec, result);
1075
+ void Promise.resolve(returned).catch(reportFailure);
1076
+ }
1077
+ catch (error) {
1078
+ reportFailure(error);
1079
+ }
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Resolve an `ask` decision to allow/deny through the approval seam. The
1084
+ * seam is consumed opportunistically with `ctx.get('approval')` — a
1085
+ * deployment that composes no ApprovalService keeps the historical degrade
1086
+ * to deny, and an unmount mid-session degrades the same way on the next ask.
1087
+ * An agent-less execution also degrades: without an agent there is no
1088
+ * session to audit to and no UI to route to. Otherwise the outcome maps
1089
+ * one-to-one — `allowed-once` proceeds; the three non-grants deny with
1090
+ * distinct reasons so the model can tell a human "no" from an absent
1091
+ * approval channel.
1092
+ */
1093
+ async serviceAsk(exec, ask) {
1094
+ const approval = this.ctx.get('approval');
1095
+ if (approval === undefined) {
1096
+ return {
1097
+ decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
1098
+ approvalCancelled: false,
1099
+ };
1100
+ }
1101
+ if (exec.agent === undefined) {
1102
+ return {
1103
+ decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
1104
+ approvalCancelled: false,
1105
+ };
1106
+ }
1107
+ const outcome = await approval.request({
1108
+ agent: exec.agent,
1109
+ toolName: exec.name,
1110
+ callId: exec.callId,
1111
+ ...ask.reason !== undefined ? { reason: ask.reason } : {},
1112
+ ...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {},
1113
+ signal: exec.signal,
1114
+ });
1115
+ switch (outcome) {
1116
+ case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false };
1117
+ case 'rejected': return {
1118
+ decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
1119
+ approvalCancelled: false,
1120
+ };
1121
+ case 'cancelled': return {
1122
+ decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
1123
+ approvalCancelled: true,
1124
+ };
1125
+ case 'unavailable': return {
1126
+ decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
1127
+ approvalCancelled: false,
1128
+ };
1129
+ default: return assertNever(outcome, 'ApprovalOutcome');
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Run the `tools/post-execute` waterfall over a dispatched `result` and apply
1134
+ * its {@link PostToolDecision}: `accept` keeps the call successful (replacing
1135
+ * `content` when given), `block` turns it into an `isError` whose content is
1136
+ * the corrective `feedback`. Either decision may attach `additionalContexts`,
1137
+ * which are ferried on the returned result for the loop's active-batch FIFO.
1138
+ * Context deferred by the tool body survives an accepted result but is
1139
+ * discarded when the outer call is blocked; a block exposes only context the
1140
+ * blocking decision explicitly supplied.
1141
+ * Runs inside `execute`'s outer try/catch (a throwing listener → isError).
1142
+ */
1143
+ async postExecute(exec, result) {
1144
+ const decision = await this.ctx.waterfall(scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }));
1145
+ const decisionContexts = decision.additionalContexts ?? [];
1146
+ if (decision.kind === 'block') {
1147
+ const message = failureMessageFromContent(decision.feedback);
1148
+ return this.markCanonical(exec, {
1149
+ content: decision.feedback,
1150
+ isError: true,
1151
+ error: { message },
1152
+ ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
1153
+ });
1154
+ }
1155
+ if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
1156
+ throw new TypeError('tools/post-execute accept decision cannot replace both value and content');
1157
+ }
1158
+ const additionalContexts = [
1159
+ ...result.additionalContexts ?? [],
1160
+ ...decisionContexts,
1161
+ ];
1162
+ if (Object.hasOwn(decision, 'value')) {
1163
+ if (result.isError) {
1164
+ throw new TypeError('tools/post-execute cannot replace the value of a failed result');
1165
+ }
1166
+ const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
1167
+ if (tool === undefined)
1168
+ throw new ToolNotFoundError(exec.name);
1169
+ const replaced = this.createSuccessResult(exec, tool, decision.value);
1170
+ return this.markCanonical(exec, {
1171
+ ...replaced,
1172
+ ...additionalContexts.length > 0 ? { additionalContexts } : {},
1173
+ });
1174
+ }
1175
+ return this.markCanonical(exec, {
1176
+ ...result,
1177
+ ...decision.content !== undefined ? { content: decision.content } : {},
1178
+ ...additionalContexts.length > 0 ? { additionalContexts } : {},
1179
+ });
1180
+ }
1181
+ /** Registry-normalized results and the exact dispatch that validated each value. */
1182
+ canonicalResults = new WeakMap();
1183
+ /** Mark one registry-normalized result as canonical only for its owning dispatch. */
1184
+ markCanonical(exec, result) {
1185
+ this.canonicalResults.set(result, exec.token);
1186
+ return result;
1187
+ }
1188
+ /** Snapshot, validate, render, and optionally project one successful body value. */
1189
+ createSuccessResult(exec, tool, candidate) {
1190
+ const detached = snapshotToolValue(tool.name, candidate);
1191
+ const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value');
1192
+ if (violations.length > 0)
1193
+ throw new ToolOutputError(tool.name, violations);
1194
+ const value = deepFreeze(detached);
1195
+ let rendered;
1196
+ try {
1197
+ rendered = tool.output.render(exec.arguments, value);
1198
+ }
1199
+ catch (error) {
1200
+ throw projectionError(tool.name, 'render', error);
1201
+ }
1202
+ const content = snapshotProjection(tool.name, 'render', rendered);
1203
+ let meta;
1204
+ if (tool.output.presentationMeta !== undefined) {
1205
+ let projected;
1206
+ try {
1207
+ projected = tool.output.presentationMeta(exec.arguments, value);
1208
+ }
1209
+ catch (error) {
1210
+ throw projectionError(tool.name, 'presentationMeta', error);
1211
+ }
1212
+ meta = snapshotProjection(tool.name, 'presentationMeta', projected);
1213
+ }
1214
+ const concludesTurn = this.concludingExecutions.has(exec);
1215
+ return this.markCanonical(exec, this.materializeFinalResult({
1216
+ isError: false,
1217
+ value,
1218
+ content,
1219
+ ...meta !== undefined ? { meta } : {},
1220
+ ...concludesTurn ? { concludesTurn: true } : {},
1221
+ }));
1222
+ }
1223
+ /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
1224
+ normalizeDispatchResult(exec, result) {
1225
+ if (this.canonicalResults.get(result) === exec.token)
1226
+ return result;
1227
+ if (result.isError) {
1228
+ return this.markCanonical(exec, {
1229
+ isError: true,
1230
+ error: result.error,
1231
+ content: result.content,
1232
+ ...result.meta !== undefined ? { meta: result.meta } : {},
1233
+ ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
1234
+ });
1235
+ }
1236
+ const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
1237
+ if (tool === undefined)
1238
+ throw new ToolNotFoundError(exec.name);
1239
+ const normalized = this.createSuccessResult(exec, tool, result.value);
1240
+ return this.markCanonical(exec, {
1241
+ ...normalized,
1242
+ ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
1243
+ });
1244
+ }
1245
+ /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
1246
+ materializeFinalResult(result) {
1247
+ const presentation = {
1248
+ content: result.content,
1249
+ ...result.meta !== undefined ? { meta: result.meta } : {},
1250
+ ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
1251
+ };
1252
+ if (result.isError) {
1253
+ return materializePresentation({ isError: true, error: result.error, ...presentation });
1254
+ }
1255
+ const detached = materializePresentation({
1256
+ isError: false,
1257
+ ...presentation,
1258
+ ...result.concludesTurn === true ? { concludesTurn: true } : {},
1259
+ });
1260
+ return deepFreeze({ ...detached, value: result.value });
1261
+ }
1262
+ }
1263
+ /** Mint a same-process correlation token whose identity is its value. */
1264
+ function createExecutionToken() {
1265
+ return Symbol('qilin.tool.execution');
1266
+ }
1267
+ function toolErrorResult(error) {
1268
+ const info = errorInfo(error);
1269
+ const message = errorMessage(error);
1270
+ return {
1271
+ content: [{ type: 'text', text: `Error: ${message}` }],
1272
+ isError: true,
1273
+ error: { message, ...info ? { info } : {} },
1274
+ };
1275
+ }
1276
+ /** Read live abort state across an await without treating it as synchronously immutable. */
1277
+ function isAborted(signal) {
1278
+ return signal.aborted;
1279
+ }
1280
+ /**
1281
+ * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
1282
+ * Keeping the relay dispatch-scoped also removes listeners when work settles.
1283
+ */
1284
+ function fuseToolSignals(caller, wrapper) {
1285
+ if (caller === wrapper)
1286
+ return { signal: caller, dispose() { } };
1287
+ const controller = new AbortController();
1288
+ let listening = false;
1289
+ const dispose = () => {
1290
+ if (!listening)
1291
+ return;
1292
+ listening = false;
1293
+ caller.removeEventListener('abort', abortFromCaller);
1294
+ wrapper.removeEventListener('abort', abortFromWrapper);
1295
+ };
1296
+ const abortFrom = (source) => {
1297
+ const reason = source.reason;
1298
+ controller.abort(reason);
1299
+ dispose();
1300
+ };
1301
+ const abortFromCaller = () => { abortFrom(caller); };
1302
+ const abortFromWrapper = () => { abortFrom(wrapper); };
1303
+ if (wrapper.aborted)
1304
+ abortFromWrapper();
1305
+ else if (caller.aborted)
1306
+ abortFromCaller();
1307
+ else {
1308
+ listening = true;
1309
+ caller.addEventListener('abort', abortFromCaller, { once: true });
1310
+ wrapper.addEventListener('abort', abortFromWrapper, { once: true });
1311
+ }
1312
+ return { signal: controller.signal, dispose };
1313
+ }
1314
+ /** Canonical result when cancellation supersedes success after body invocation. */
1315
+ function toolAbortedResult(prior) {
1316
+ const additionalContexts = prior?.additionalContexts ?? [];
1317
+ return {
1318
+ content: [{ type: 'text', text: 'Error: tool call aborted' }],
1319
+ isError: true,
1320
+ error: {
1321
+ message: 'tool call aborted',
1322
+ info: { name: 'AbortError', code: TOOL_ABORTED },
1323
+ },
1324
+ ...additionalContexts.length > 0 ? { additionalContexts } : {},
1325
+ };
1326
+ }
1327
+ /** Canonical result when cancellation prevents tool body invocation. */
1328
+ function toolAbortedBeforeDispatchResult(prior) {
1329
+ const additionalContexts = prior?.additionalContexts ?? [];
1330
+ return {
1331
+ content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
1332
+ isError: true,
1333
+ error: {
1334
+ message: 'tool call aborted before dispatch',
1335
+ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
1336
+ },
1337
+ ...additionalContexts.length > 0 ? { additionalContexts } : {},
1338
+ };
1339
+ }
1340
+ export default ToolRuntime;
1341
+ //# sourceMappingURL=index.js.map