@qilin-agent/tools 0.0.0-stage → 3.1.3
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/LICENSE +21 -0
- package/README.i18n.yaml +103 -0
- package/README.md +239 -2
- package/README.zh.md +240 -0
- package/lib/index.js +3705 -0
- package/lib/types/index.d.ts +844 -0
- package/lib/types/index.js +1334 -0
- package/lib/types/json-schema.d.ts +106 -0
- package/lib/types/json-schema.js +577 -0
- package/lib/types/presentation.d.ts +367 -0
- package/lib/types/presentation.js +8 -0
- package/lib/types/ptc.d.ts +80 -0
- package/lib/types/ptc.js +660 -0
- package/lib/types/py-types.d.ts +49 -0
- package/lib/types/py-types.js +788 -0
- package/lib/types/schema.d.ts +243 -0
- package/lib/types/schema.js +350 -0
- package/lib/types/testing.d.ts +25 -0
- package/lib/types/testing.js +25 -0
- package/lib/types/ts-types.d.ts +37 -0
- package/lib/types/ts-types.js +291 -0
- package/lib/types/types.d.ts +61 -0
- package/lib/types/types.js +7 -0
- package/package.json +68 -3
|
@@ -0,0 +1,1334 @@
|
|
|
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', 'both']).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
|
+
* `both` renders empty: native calls do execute there, so the rule is false.
|
|
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
|
+
requireRuntime: () => this.requirePtcRuntime(this.defaultMode),
|
|
352
|
+
peekApprover: () => this.ctx.get('approval'),
|
|
353
|
+
resolveSandboxPolicy: (exec) => {
|
|
354
|
+
const policy = this.ctx.get('sandboxPolicy');
|
|
355
|
+
if (policy === undefined)
|
|
356
|
+
throw new Error('qilin-tools: confined PTC runtime requires sandboxPolicy');
|
|
357
|
+
return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
|
|
358
|
+
},
|
|
359
|
+
// The language-aware description/parameters getters read the runtime
|
|
360
|
+
// without demanding one, so a native-default process can still project
|
|
361
|
+
// the transport for an agent that chose code.
|
|
362
|
+
peekRuntime: () => this.ctx.get('ptcRuntime'),
|
|
363
|
+
maxParallel: this.maxParallelSubCalls,
|
|
364
|
+
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
|
|
365
|
+
});
|
|
366
|
+
return this.ptcTransport;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Present the calling scope's tools in `mode` instead of the deployment
|
|
370
|
+
* default. Nearest scope on the chain wins, so a preset's standing
|
|
371
|
+
* declaration covers every agent joined under it.
|
|
372
|
+
*
|
|
373
|
+
* Scoped only, and one declaration per scope: this is how an agent preset
|
|
374
|
+
* composes PTC mode agents beside native ones in the same process, and a
|
|
375
|
+
* process-global override would be the `mode` config field instead.
|
|
376
|
+
* @param mode - the presentation the covered agents' models see.
|
|
377
|
+
* @returns the exact disposer that restores the deployment default.
|
|
378
|
+
*/
|
|
379
|
+
presentAs(mode) {
|
|
380
|
+
const ctx = this.ctx;
|
|
381
|
+
if (scopeOf(ctx) === undefined) {
|
|
382
|
+
throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row');
|
|
383
|
+
}
|
|
384
|
+
const dispose = ctx.effect(function* () {
|
|
385
|
+
yield this.layers.effect(ctx, (layer) => {
|
|
386
|
+
if (layer.mode !== undefined) {
|
|
387
|
+
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`);
|
|
388
|
+
}
|
|
389
|
+
layer.mode = mode;
|
|
390
|
+
return () => { layer.mode = undefined; };
|
|
391
|
+
}, { label: 'tools.presentAs()' });
|
|
392
|
+
// The SDK and collapse sections are per scope for the same reason the
|
|
393
|
+
// mode is. Under a deployment that already defaults to PTC mode this
|
|
394
|
+
// shadows the global registration with an identical body, which costs
|
|
395
|
+
// nothing and keeps one rule instead of a case analysis.
|
|
396
|
+
if (mode !== 'native') {
|
|
397
|
+
yield ctx.systemPrompt.section(this.collapseSection());
|
|
398
|
+
yield ctx.systemPrompt.section(this.sdkSection());
|
|
399
|
+
}
|
|
400
|
+
}.bind(this), 'tools.presentAs()');
|
|
401
|
+
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown
|
|
402
|
+
return dispose;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Build one scope's wire schemas and names for prompt-order validation.
|
|
406
|
+
* Restrictions do not make known tools invalid, but a mode collapse does.
|
|
407
|
+
*/
|
|
408
|
+
wireSchemas(scope) {
|
|
409
|
+
const view = this.view(scope);
|
|
410
|
+
const mode = this.modeFor(scope);
|
|
411
|
+
if (mode === 'native') {
|
|
412
|
+
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false));
|
|
413
|
+
return { schemas, knownNames: [...view.knownNames] };
|
|
414
|
+
}
|
|
415
|
+
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
|
|
416
|
+
// run_code's language-aware description/parameters getters, whose own
|
|
417
|
+
// flavor-table guard would otherwise surface first. This keeps the
|
|
418
|
+
// renderer-table rejection the canonical assembly-time error for a
|
|
419
|
+
// language with no SDK renderer.
|
|
420
|
+
this.requirePtcRuntime(mode);
|
|
421
|
+
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false));
|
|
422
|
+
if (mode === 'ptc') {
|
|
423
|
+
return {
|
|
424
|
+
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
|
|
425
|
+
knownNames: [RUN_CODE_NAME],
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Resolve the PTC runtime or throw the actionable misconfiguration error.
|
|
432
|
+
* Read at use time (assembly / run_code execution), NOT via static
|
|
433
|
+
* `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
|
|
434
|
+
* behind it — hostage to a PTC runtime existing even under `mode:
|
|
435
|
+
* 'native'`.
|
|
436
|
+
*
|
|
437
|
+
* Assembly and `run_code` execution read separately, so the language is not
|
|
438
|
+
* bound to a request. Harmless while one published backend exists — both
|
|
439
|
+
* reads return the same flavor — but a reload that swapped in a second
|
|
440
|
+
* language between them would hand a program written against one SDK to the
|
|
441
|
+
* other. Binding it is deferred until a second backend ships (the first
|
|
442
|
+
* point it is testable).
|
|
443
|
+
*/
|
|
444
|
+
requirePtcRuntime(mode) {
|
|
445
|
+
const runtime = this.ctx.get('ptcRuntime');
|
|
446
|
+
if (!runtime) {
|
|
447
|
+
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"`);
|
|
448
|
+
}
|
|
449
|
+
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
|
|
450
|
+
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ');
|
|
451
|
+
throw new Error(`qilin-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`);
|
|
452
|
+
}
|
|
453
|
+
return runtime;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Register globally or in the calling agent scope. Scoped tools shadow
|
|
457
|
+
* globals; duplicates within one layer and the reserved `run_code` name fail.
|
|
458
|
+
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
|
|
459
|
+
* @returns the exact disposer that unregisters the tool.
|
|
460
|
+
*/
|
|
461
|
+
register(definition) {
|
|
462
|
+
const name = definition.name;
|
|
463
|
+
const output = definition.output;
|
|
464
|
+
if (output === undefined || typeof output !== 'object'
|
|
465
|
+
|| typeof output.render !== 'function'
|
|
466
|
+
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
|
|
467
|
+
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`);
|
|
468
|
+
}
|
|
469
|
+
assertSupportedJsonSchema(output.schema);
|
|
470
|
+
const timeoutMs = definition.timeoutMs;
|
|
471
|
+
if (timeoutMs !== undefined
|
|
472
|
+
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
473
|
+
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`);
|
|
474
|
+
}
|
|
475
|
+
// Reserved unconditionally: any agent may select a code mode for itself,
|
|
476
|
+
// so a name free to take under the deployment default would become a
|
|
477
|
+
// collision the moment a preset mounted.
|
|
478
|
+
if (name === RUN_CODE_NAME) {
|
|
479
|
+
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the PTC mode presentation transport and cannot be registered or shadowed`);
|
|
480
|
+
}
|
|
481
|
+
return this.layers.effect(this.ctx, layer => layer.tools.insert(name, definition), { label: 'tools.register()' });
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Restrict global tools for the calling agent scope. Empty filters, unknown
|
|
485
|
+
* names, scope-local names, and reserved transport names fail. Restrictions
|
|
486
|
+
* intersect; scoped registrations remain visible.
|
|
487
|
+
* @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
|
|
488
|
+
* @returns the exact disposer that lifts this restriction.
|
|
489
|
+
*/
|
|
490
|
+
restrict(filter) {
|
|
491
|
+
const scope = scopeOf(this.ctx);
|
|
492
|
+
if (scope === undefined) {
|
|
493
|
+
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');
|
|
494
|
+
}
|
|
495
|
+
const allow = filter.allow;
|
|
496
|
+
const deny = filter.deny;
|
|
497
|
+
if (allow === undefined && deny === undefined) {
|
|
498
|
+
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)');
|
|
499
|
+
}
|
|
500
|
+
const compiled = {
|
|
501
|
+
...allow !== undefined ? { allow: new Set(allow) } : {},
|
|
502
|
+
...deny !== undefined ? { deny: new Set(deny) } : {},
|
|
503
|
+
};
|
|
504
|
+
if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
|
|
505
|
+
throw new Error(`tools.restrict() cannot name reserved PTC mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`);
|
|
506
|
+
}
|
|
507
|
+
const known = this.view(scope).restrictableNames;
|
|
508
|
+
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name));
|
|
509
|
+
if (unknown.length > 0) {
|
|
510
|
+
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)'}`);
|
|
511
|
+
}
|
|
512
|
+
return this.layers.effect(this.ctx, layer => layer.restrictions.append(compiled), { label: 'tools.restrict()' });
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Register a monotonic guard after the extensible `tools/pre-execute`
|
|
516
|
+
* waterfall. A plain-context guard applies globally; one registered through
|
|
517
|
+
* `agent.ctx` applies only to that agent. Any matching guard may deny by
|
|
518
|
+
* returning a reason, while no guard can force-allow a call another guard
|
|
519
|
+
* denied. The exact effect disposer is returned for ordered ownership and
|
|
520
|
+
* HMR cleanup.
|
|
521
|
+
* @param guard - synchronous check; a returned string denies the execution.
|
|
522
|
+
* @returns the exact disposer that unregisters the guard.
|
|
523
|
+
*/
|
|
524
|
+
guard(guard) {
|
|
525
|
+
return this.layers.effect(this.ctx, layer => layer.guards.append(guard), { label: 'tools.guard()', notify: false });
|
|
526
|
+
}
|
|
527
|
+
/** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
|
|
528
|
+
guardReason(exec) {
|
|
529
|
+
const globalReason = this.layers.global.guardReason(exec);
|
|
530
|
+
if (globalReason !== undefined)
|
|
531
|
+
return globalReason;
|
|
532
|
+
if (exec.agent === undefined)
|
|
533
|
+
return undefined;
|
|
534
|
+
for (const layer of this.layers.chainLayers(exec.agent)) {
|
|
535
|
+
const reason = layer.guardReason(exec);
|
|
536
|
+
if (reason !== undefined)
|
|
537
|
+
return reason;
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Resolve every registry fact one scope needs in one layer traversal. The
|
|
543
|
+
* visible map applies restrictions to the INHERITED surface, then the
|
|
544
|
+
* scope's own registrations and the reserved presentation transport; the
|
|
545
|
+
* other sets retain the pre-restriction facts needed by restriction and
|
|
546
|
+
* prompt-order validation.
|
|
547
|
+
*
|
|
548
|
+
* A restriction filters what a scope inherits — the global layer and every
|
|
549
|
+
* ancestor layer on its chain — and never what its OWN layer registers.
|
|
550
|
+
* That exemption is what a per-child capability filter has to keep intact:
|
|
551
|
+
* the delegation runtime registers a child's structured-output tool into the
|
|
552
|
+
* child's own layer, and a filter naming the capabilities the child may use
|
|
553
|
+
* must not strip the machinery it answers through.
|
|
554
|
+
*
|
|
555
|
+
* Reading the exempt set as "the global layer" instead of "not mine" held
|
|
556
|
+
* only while every model-facing tool sat in the host composition. Once
|
|
557
|
+
* presets moved them onto the agent plane they became an ANCESTOR
|
|
558
|
+
* contribution, so a child's filter silently stopped constraining anything
|
|
559
|
+
* it was given.
|
|
560
|
+
* @param scope - the viewing scope (the agent), or undefined for the global view.
|
|
561
|
+
* @returns the complete derived view for that scope.
|
|
562
|
+
*/
|
|
563
|
+
view(scope) {
|
|
564
|
+
// Scope-chain layers, farthest ancestor first, the exact scope last.
|
|
565
|
+
const layers = this.layers.chainLayers(scope);
|
|
566
|
+
// Chain-blind on purpose: this is the ONE layer whose registrations the
|
|
567
|
+
// scope owns rather than inherits, and it is absent until the scope
|
|
568
|
+
// contributes something.
|
|
569
|
+
const own = this.layers.peek(scope);
|
|
570
|
+
// Inherited surface, nearest ancestor last: a nearer scope's same-name
|
|
571
|
+
// entry shadows a farther one, and the global layer is the farthest.
|
|
572
|
+
const inherited = new Map(this.layers.global.tools.entries());
|
|
573
|
+
for (const layer of layers) {
|
|
574
|
+
if (layer === own)
|
|
575
|
+
continue;
|
|
576
|
+
for (const [name, definition] of layer.tools.entries())
|
|
577
|
+
inherited.set(name, definition);
|
|
578
|
+
}
|
|
579
|
+
const visible = new Map();
|
|
580
|
+
const knownNames = new Set();
|
|
581
|
+
const restrictableNames = new Set();
|
|
582
|
+
for (const [name, definition] of inherited) {
|
|
583
|
+
knownNames.add(name);
|
|
584
|
+
restrictableNames.add(name);
|
|
585
|
+
// Restrictions intersect across the whole chain: any scope on it may
|
|
586
|
+
// mask an inherited name for everything nested inside it.
|
|
587
|
+
if (layers.every(layer => layer.admits(name)))
|
|
588
|
+
visible.set(name, definition);
|
|
589
|
+
}
|
|
590
|
+
// The scope's own registrations last, shadowing an inherited name and
|
|
591
|
+
// outside the filter above.
|
|
592
|
+
if (own !== undefined) {
|
|
593
|
+
for (const [name, definition] of own.tools.entries()) {
|
|
594
|
+
knownNames.add(name);
|
|
595
|
+
visible.set(name, definition);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Presentation infrastructure is resolved last and outside capability
|
|
599
|
+
// filtering. Registration rejects this reserved name, so the insertion is
|
|
600
|
+
// an invariant assertion as well as protection against future layer
|
|
601
|
+
// changes. Per scope: a native agent must not find `run_code` in its
|
|
602
|
+
// dispatch table because some other agent in the process presents it.
|
|
603
|
+
if (this.modeFor(scope) !== 'native') {
|
|
604
|
+
visible.set(RUN_CODE_NAME, this.requirePtcTransport());
|
|
605
|
+
}
|
|
606
|
+
return { visible, knownNames, restrictableNames };
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Look up a tool as one scope sees it (scoped
|
|
610
|
+
* shadows global; a restricted-away global reads as absent). Presenters pass
|
|
611
|
+
* the calling agent so the rendered card matches the definition that
|
|
612
|
+
* actually executed.
|
|
613
|
+
* @param name - the tool name as registered.
|
|
614
|
+
* @param scope - the viewing scope (the agent); omitted = the global view.
|
|
615
|
+
* @returns the definition the scope resolves, or undefined when none is visible.
|
|
616
|
+
*/
|
|
617
|
+
get(name, scope) {
|
|
618
|
+
return this.view(scope).visible.get(name);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Resolve the definition that MAY EXECUTE for a call, applying the mode
|
|
622
|
+
* collapse at the operation boundary that owns it. The registry view
|
|
623
|
+
* (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc`
|
|
624
|
+
* may only name the reserved `run_code` transport, while a nested
|
|
625
|
+
* sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool
|
|
626
|
+
* it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL`
|
|
627
|
+
* through the executor, matching an absent definition.
|
|
628
|
+
* @param name - the tool name as registered.
|
|
629
|
+
* @param scope - the viewing scope (the agent); omitted = the global view.
|
|
630
|
+
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
|
|
631
|
+
* @returns the definition that may run, or undefined when the call must be rejected.
|
|
632
|
+
*/
|
|
633
|
+
resolveExecution(name, scope, nested) {
|
|
634
|
+
const tool = this.get(name, scope);
|
|
635
|
+
if (tool === undefined)
|
|
636
|
+
return undefined;
|
|
637
|
+
if (this.collapses(name, scope, nested))
|
|
638
|
+
return undefined;
|
|
639
|
+
return tool;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Project visible definitions onto the allowlisted model-facing schema fields,
|
|
643
|
+
* excluding execution and presentation callbacks.
|
|
644
|
+
* @param scope - the viewing scope (the agent); omitted = the global view.
|
|
645
|
+
* @returns one deep-cloned schema per visible tool.
|
|
646
|
+
*/
|
|
647
|
+
schemas(scope) {
|
|
648
|
+
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true));
|
|
649
|
+
}
|
|
650
|
+
/** Project visible callable tools onto the generated PTC mode SDK contract. */
|
|
651
|
+
sdkSchemas(scope) {
|
|
652
|
+
return [...this.view(scope).visible.values()]
|
|
653
|
+
.filter(definition => definition.name !== RUN_CODE_NAME)
|
|
654
|
+
.map((definition) => {
|
|
655
|
+
const output = snapshotJsonValue(definition.output.schema);
|
|
656
|
+
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
|
|
657
|
+
if (output === undefined) {
|
|
658
|
+
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`);
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
...this.schemaOf(definition, true),
|
|
662
|
+
output,
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/** Project one definition onto the model-facing schema fields. */
|
|
667
|
+
schemaOf(definition, detachParameters) {
|
|
668
|
+
const { name, description, parameters, deferLoading } = definition;
|
|
669
|
+
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters;
|
|
670
|
+
if (detached === undefined) {
|
|
671
|
+
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`);
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
name,
|
|
675
|
+
description,
|
|
676
|
+
parameters: detached,
|
|
677
|
+
...deferLoading === true ? { deferLoading } : {},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Classify a pending call through the caller's visible tool definition. Only
|
|
682
|
+
* an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
|
|
683
|
+
* throwing classifiers are exclusive.
|
|
684
|
+
* @param exec - call name, parsed arguments, and optional agent scope.
|
|
685
|
+
* @returns the fail-closed scheduling mode.
|
|
686
|
+
*/
|
|
687
|
+
executionMode(exec) {
|
|
688
|
+
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
|
|
689
|
+
if (!tool?.isConcurrencySafe)
|
|
690
|
+
return { kind: 'exclusive' };
|
|
691
|
+
try {
|
|
692
|
+
const concurrencySafe = tool.isConcurrencySafe(exec.arguments);
|
|
693
|
+
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' };
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return { kind: 'exclusive' };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch
|
|
701
|
+
* and return the content the bridge should log on `tool/ptc-dispatch`.
|
|
702
|
+
* Contained: when a listener throws, the method logs the original settled
|
|
703
|
+
* content; that failure must not fail the dispatch or omit the settle event. Private:
|
|
704
|
+
* the ONE consumer is the `run_code` bridge this registry constructs, which
|
|
705
|
+
* receives it as a capability parameter (the `requireRuntime` idiom) — the
|
|
706
|
+
* waterfall, not this invoker, is the public extension point.
|
|
707
|
+
*/
|
|
708
|
+
async shapeDispatchLog(dispatch) {
|
|
709
|
+
try {
|
|
710
|
+
return await this.ctx.waterfall(scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, () => Promise.resolve(dispatch.content));
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`);
|
|
714
|
+
return dispatch.content;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Whether the `ptc` mode collapse denies a model-direct call: only the
|
|
719
|
+
* reserved `run_code` transport may be named. Nested sub-dispatches (a
|
|
720
|
+
* `parent` token set) bypass the collapse. One home for the
|
|
721
|
+
* security-relevant predicate, shared by {@link resolveExecution} and
|
|
722
|
+
* {@link createExecution} so the two can never drift apart.
|
|
723
|
+
*
|
|
724
|
+
* Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc`
|
|
725
|
+
* by an agent preset under a native deployment is the composition
|
|
726
|
+
* `qilin-agent-tool-presentation` exists for, and reading the deployment default would
|
|
727
|
+
* leave exactly that agent uncollapsed — announcing one surface while
|
|
728
|
+
* executing another, which is the bypass this collapse closes.
|
|
729
|
+
* @param name - the tool name as registered.
|
|
730
|
+
* @param scope - the viewing scope whose effective presentation mode applies.
|
|
731
|
+
* @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
|
|
732
|
+
*/
|
|
733
|
+
collapses(name, scope, nested) {
|
|
734
|
+
return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Execute through pre-policy, guards, around-dispatch, post-policy,
|
|
738
|
+
* definition-owned content finalization, and final notification. Tool and
|
|
739
|
+
* listener failures resolve as materialized error results; an invisible tool
|
|
740
|
+
* reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
|
|
741
|
+
* snapshot final observers receive. Cancellation
|
|
742
|
+
* arriving after entry and before final result materialization skips a
|
|
743
|
+
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
|
|
744
|
+
* successful started outcome with `ABORTED`; already-started work is still
|
|
745
|
+
* drained and may retain a tool-owned structured error.
|
|
746
|
+
* @param exec - the typed same-process call input. The registry assigns its
|
|
747
|
+
* correlation token before policy begins.
|
|
748
|
+
* @returns the materialized final result.
|
|
749
|
+
*/
|
|
750
|
+
async execute(exec) {
|
|
751
|
+
return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared));
|
|
752
|
+
}
|
|
753
|
+
async completeScheduledExecution(prepared) {
|
|
754
|
+
switch (prepared.kind) {
|
|
755
|
+
case 'dispatch': {
|
|
756
|
+
const dispatched = await this.dispatchScheduledExecution(prepared.exec);
|
|
757
|
+
return dispatched.kind === 'post-result'
|
|
758
|
+
? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
|
|
759
|
+
: this.finishScheduledExecution(prepared.exec, dispatched.result);
|
|
760
|
+
}
|
|
761
|
+
case 'post-result':
|
|
762
|
+
return await this.finalizeScheduledExecution(prepared.exec, prepared.result);
|
|
763
|
+
case 'final-result':
|
|
764
|
+
return this.finishScheduledExecution(prepared.exec, prepared.result);
|
|
765
|
+
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
766
|
+
default:
|
|
767
|
+
return assertNever(prepared, 'scheduled tool preparation');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
createExecution(exec) {
|
|
771
|
+
const deferredContexts = [];
|
|
772
|
+
const token = createExecutionToken();
|
|
773
|
+
const callId = exec.callId;
|
|
774
|
+
const rootCallId = exec.rootCallId ?? callId;
|
|
775
|
+
const name = exec.name;
|
|
776
|
+
const agent = exec.agent;
|
|
777
|
+
const parent = exec.parent;
|
|
778
|
+
const signal = exec.signal;
|
|
779
|
+
// Distinguish a mode-collapsed call (visible in the scope, denied only by
|
|
780
|
+
// the `ptc` collapse) from a genuinely unknown tool. A collapsed call is
|
|
781
|
+
// deterministically denied, so it terminates BEFORE the extensible policy
|
|
782
|
+
// pipeline: pre-execute listeners, approval `ask`, and guards must never
|
|
783
|
+
// observe — or worse, approve — a call that can only fail. An unknown tool
|
|
784
|
+
// keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy
|
|
785
|
+
// listeners still see every name that reaches the registry.
|
|
786
|
+
const visible = this.get(name, agent);
|
|
787
|
+
const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined);
|
|
788
|
+
const concludingExecutions = this.concludingExecutions;
|
|
789
|
+
const base = {
|
|
790
|
+
token,
|
|
791
|
+
callId,
|
|
792
|
+
rootCallId,
|
|
793
|
+
name,
|
|
794
|
+
signal,
|
|
795
|
+
...agent !== undefined ? { agent } : {},
|
|
796
|
+
...parent !== undefined ? { parent } : {},
|
|
797
|
+
...exec.schema !== undefined ? { schema: exec.schema } : {},
|
|
798
|
+
deferContext(context) {
|
|
799
|
+
deferredContexts.push(context);
|
|
800
|
+
},
|
|
801
|
+
concludeTurn() {
|
|
802
|
+
concludingExecutions.add(this);
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
// Capture the finalizer BEFORE argument materialization: the
|
|
806
|
+
// `finalizeContent` contract snapshots the callback when the call starts,
|
|
807
|
+
// and an arguments getter can replace or clear the registered callback
|
|
808
|
+
// during `snapshotJsonValue`. The collapse only decides whether the
|
|
809
|
+
// CAPTURED callback is retained: the pre-dispatch abort path keeps it
|
|
810
|
+
// (the cancellation contract routes aborted results through it — a getter
|
|
811
|
+
// that aborts mid-materialization before an invalid-args failure lands in
|
|
812
|
+
// the same retained path), while the `UNKNOWN_TOOL` denial and the
|
|
813
|
+
// invalid-args failure of a NON-ABORTED collapsed call drop it (the call
|
|
814
|
+
// could never execute).
|
|
815
|
+
const capturedFinalizer = visible?.finalizeContent?.bind(visible);
|
|
816
|
+
const finalizerFor = () => collapsed && !signal.aborted ? undefined : capturedFinalizer;
|
|
817
|
+
try {
|
|
818
|
+
const detached = snapshotJsonValue(exec.arguments);
|
|
819
|
+
if (detached === undefined) {
|
|
820
|
+
throw new TypeError('tool execution arguments must be losslessly JSON-serializable');
|
|
821
|
+
}
|
|
822
|
+
const execution = { ...base, arguments: deepFreeze(detached) };
|
|
823
|
+
this.deferredContexts.set(execution, deferredContexts);
|
|
824
|
+
this.contentFinalizers.set(execution, finalizerFor());
|
|
825
|
+
this.cancellationStates.set(execution, {
|
|
826
|
+
callerSignal: signal,
|
|
827
|
+
bodyInvoked: false,
|
|
828
|
+
});
|
|
829
|
+
if (collapsed) {
|
|
830
|
+
// The collapse denies the call before the policy pipeline, but a
|
|
831
|
+
// pre-dispatch abort still keeps the established cancellation
|
|
832
|
+
// contract: `prepare`'s caller-cancellation check is skipped for
|
|
833
|
+
// final-results, so honor the abort here instead of surfacing
|
|
834
|
+
// `UNKNOWN_TOOL` on an already-cancelled call.
|
|
835
|
+
if (signal.aborted) {
|
|
836
|
+
return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() };
|
|
837
|
+
}
|
|
838
|
+
// The name IS visible here, so the denial carries the route the model
|
|
839
|
+
// must take instead. Without it the model reads a bare `unknown tool`
|
|
840
|
+
// for a tool the prompt just declared and concludes the deployment is
|
|
841
|
+
// broken rather than correcting itself.
|
|
842
|
+
return {
|
|
843
|
+
kind: 'final-result',
|
|
844
|
+
exec: execution,
|
|
845
|
+
result: toolErrorResult(new ToolNotFoundError(name, `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`)),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
return { kind: 'ready', exec: execution };
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
const execution = { ...base, arguments: undefined };
|
|
852
|
+
this.contentFinalizers.set(execution, finalizerFor());
|
|
853
|
+
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) };
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Run the ordered pre-execute and monotonic guard stages for the scheduler.
|
|
858
|
+
* @param input - the caller-supplied execution input.
|
|
859
|
+
* @returns the prepared execution plus the next scheduler stage.
|
|
860
|
+
* @internal
|
|
861
|
+
*/
|
|
862
|
+
async prepareScheduledExecution(input) {
|
|
863
|
+
return this.prepareExecution(input, prepared => prepared);
|
|
864
|
+
}
|
|
865
|
+
async prepareExecution(input, next) {
|
|
866
|
+
const created = this.createExecution(input);
|
|
867
|
+
if (created.kind !== 'ready')
|
|
868
|
+
return next(created);
|
|
869
|
+
const exec = created.exec;
|
|
870
|
+
if (this.callerCancelled(exec)) {
|
|
871
|
+
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() });
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const carrier = scopeTarget(this, exec.agent);
|
|
875
|
+
const gate = await this.ctx.waterfall(carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }));
|
|
876
|
+
const askResolution = gate.kind === 'ask'
|
|
877
|
+
? await this.serviceAsk(exec, gate)
|
|
878
|
+
: { decision: gate, approvalCancelled: false };
|
|
879
|
+
const { decision } = askResolution;
|
|
880
|
+
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
|
|
881
|
+
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
|
|
882
|
+
}
|
|
883
|
+
if (decision.kind === 'cancel') {
|
|
884
|
+
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
|
|
885
|
+
}
|
|
886
|
+
const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason;
|
|
887
|
+
const denialInfo = decision.kind === 'deny' ? decision.info : undefined;
|
|
888
|
+
if (denialReason !== undefined) {
|
|
889
|
+
return await next({
|
|
890
|
+
kind: 'post-result',
|
|
891
|
+
exec,
|
|
892
|
+
result: this.materializeFinalResult({
|
|
893
|
+
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
|
894
|
+
isError: true,
|
|
895
|
+
error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } },
|
|
896
|
+
}),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (this.callerCancelled(exec)) {
|
|
900
|
+
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() });
|
|
901
|
+
}
|
|
902
|
+
return await next({ kind: 'dispatch', exec });
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
return next({ kind: 'final-result', exec, result: toolErrorResult(error) });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** Whether the original caller signal is currently aborted. */
|
|
909
|
+
callerCancelled(exec) {
|
|
910
|
+
const state = this.cancellationStates.get(exec);
|
|
911
|
+
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
912
|
+
if (state === undefined)
|
|
913
|
+
throw new Error('tool registry scheduler invariant violated: missing cancellation state');
|
|
914
|
+
return state.callerSignal.aborted;
|
|
915
|
+
}
|
|
916
|
+
/** Canonical cancellation outcome selected by whether the tool body started. */
|
|
917
|
+
cancellationResult(exec, prior) {
|
|
918
|
+
const state = this.cancellationStates.get(exec);
|
|
919
|
+
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
920
|
+
if (state === undefined)
|
|
921
|
+
throw new Error('tool registry scheduler invariant violated: missing cancellation state');
|
|
922
|
+
return state.bodyInvoked
|
|
923
|
+
? toolAbortedResult(prior)
|
|
924
|
+
: toolAbortedBeforeDispatchResult(prior);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Dispatch the registered body with the original caller signal fused back
|
|
928
|
+
* into any around-wrapper replacement. Cancellation never abandons the body:
|
|
929
|
+
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
|
|
930
|
+
*/
|
|
931
|
+
async dispatchToolBody(exec) {
|
|
932
|
+
const state = this.cancellationStates.get(exec);
|
|
933
|
+
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
934
|
+
if (state === undefined)
|
|
935
|
+
throw new Error('tool registry scheduler invariant violated: missing cancellation state');
|
|
936
|
+
const wrapperSignal = exec.signal;
|
|
937
|
+
const fused = fuseToolSignals(state.callerSignal, wrapperSignal);
|
|
938
|
+
const signal = fused.signal;
|
|
939
|
+
if (isAborted(signal)) {
|
|
940
|
+
fused.dispose();
|
|
941
|
+
return toolAbortedBeforeDispatchResult();
|
|
942
|
+
}
|
|
943
|
+
exec.signal = signal;
|
|
944
|
+
try {
|
|
945
|
+
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
|
|
946
|
+
if (!tool)
|
|
947
|
+
throw new ToolNotFoundError(exec.name);
|
|
948
|
+
state.bodyInvoked = true;
|
|
949
|
+
const returned = await tool.execute(exec.arguments, exec);
|
|
950
|
+
const result = this.createSuccessResult(exec, tool, returned);
|
|
951
|
+
return isAborted(signal)
|
|
952
|
+
? toolAbortedResult(result)
|
|
953
|
+
: result;
|
|
954
|
+
}
|
|
955
|
+
catch (error) {
|
|
956
|
+
return toolErrorResult(error);
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
fused.dispose();
|
|
960
|
+
exec.signal = wrapperSignal;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Run around-dispatch and the tool body. Tool and unknown-tool failures still
|
|
965
|
+
* receive post-execute; pipeline failures are already final.
|
|
966
|
+
* @param exec - the prepared execution.
|
|
967
|
+
* @returns whether the result still needs post-execute.
|
|
968
|
+
* @internal
|
|
969
|
+
*/
|
|
970
|
+
async dispatchScheduledExecution(exec) {
|
|
971
|
+
try {
|
|
972
|
+
const mutableExec = exec;
|
|
973
|
+
const carrier = scopeTarget(this, exec.agent);
|
|
974
|
+
const result = await this.ctx.waterfall(carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec));
|
|
975
|
+
const normalized = this.normalizeDispatchResult(exec, result);
|
|
976
|
+
const deferredContexts = this.deferredContexts.get(exec);
|
|
977
|
+
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
|
|
978
|
+
if (deferredContexts === undefined)
|
|
979
|
+
throw new Error('tool registry scheduler invariant violated: unprepared execution');
|
|
980
|
+
const resultWithDeferredContexts = deferredContexts.length === 0
|
|
981
|
+
? normalized
|
|
982
|
+
: this.markCanonical(exec, {
|
|
983
|
+
...normalized,
|
|
984
|
+
additionalContexts: [
|
|
985
|
+
...deferredContexts,
|
|
986
|
+
...normalized.additionalContexts ?? [],
|
|
987
|
+
],
|
|
988
|
+
});
|
|
989
|
+
return {
|
|
990
|
+
kind: 'post-result',
|
|
991
|
+
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
|
|
992
|
+
? this.cancellationResult(exec, resultWithDeferredContexts)
|
|
993
|
+
: resultWithDeferredContexts,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
catch (error) {
|
|
997
|
+
return { kind: 'final-result', result: toolErrorResult(error) };
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Run ordered post-execute, then apply definition-owned content finalization,
|
|
1002
|
+
* materialize, and notify the final outcome.
|
|
1003
|
+
* @param exec - the prepared execution.
|
|
1004
|
+
* @param result - dispatch/pre result that still needs post-execute.
|
|
1005
|
+
* @returns the materialized final result.
|
|
1006
|
+
* @internal
|
|
1007
|
+
*/
|
|
1008
|
+
async finalizeScheduledExecution(exec, result) {
|
|
1009
|
+
try {
|
|
1010
|
+
const postResult = await this.postExecute(exec, result);
|
|
1011
|
+
return this.finishScheduledExecution(exec, this.callerCancelled(exec) && !postResult.isError
|
|
1012
|
+
? this.cancellationResult(exec, postResult)
|
|
1013
|
+
: postResult);
|
|
1014
|
+
}
|
|
1015
|
+
catch (error) {
|
|
1016
|
+
return this.finishScheduledExecution(exec, toolErrorResult(error));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Materialize the candidate, apply definition-owned content finalization,
|
|
1021
|
+
* then materialize and notify the authoritative result.
|
|
1022
|
+
* @param exec - the prepared execution.
|
|
1023
|
+
* @param result - final result.
|
|
1024
|
+
* @returns the materialized final result.
|
|
1025
|
+
* @internal
|
|
1026
|
+
*/
|
|
1027
|
+
finishScheduledExecution(exec, result) {
|
|
1028
|
+
let materializedResult;
|
|
1029
|
+
try {
|
|
1030
|
+
materializedResult = this.materializeFinalResult(result);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
materializedResult = this.materializeFinalResult(toolErrorResult(error));
|
|
1034
|
+
}
|
|
1035
|
+
let finalResult;
|
|
1036
|
+
try {
|
|
1037
|
+
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult));
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
finalResult = this.materializeFinalResult(toolErrorResult(error));
|
|
1041
|
+
}
|
|
1042
|
+
this.notifyResult(exec, finalResult);
|
|
1043
|
+
return finalResult;
|
|
1044
|
+
}
|
|
1045
|
+
/** Apply the snapshotted tool-owned content transform without exposing other result fields. */
|
|
1046
|
+
applyFinalContent(exec, result) {
|
|
1047
|
+
const finalizeContent = this.contentFinalizers.get(exec);
|
|
1048
|
+
if (finalizeContent === undefined)
|
|
1049
|
+
return result;
|
|
1050
|
+
const content = finalizeContent(exec, result);
|
|
1051
|
+
return content === undefined ? result : { ...result, content };
|
|
1052
|
+
}
|
|
1053
|
+
/** Notify observers without exposing a mutation or error channel into the outcome. */
|
|
1054
|
+
notifyResult(exec, result) {
|
|
1055
|
+
// Freeze the registry's live object before observers receive its readonly
|
|
1056
|
+
// WeakMap-keyable view.
|
|
1057
|
+
Object.freeze(exec);
|
|
1058
|
+
const { name: toolName, callId } = exec;
|
|
1059
|
+
const reportFailure = (error) => {
|
|
1060
|
+
this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`);
|
|
1061
|
+
};
|
|
1062
|
+
const callbacks = this.ctx.events.dispatch('emit', [
|
|
1063
|
+
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
|
1064
|
+
]);
|
|
1065
|
+
for (const callback of callbacks) {
|
|
1066
|
+
try {
|
|
1067
|
+
const returned = callback(exec, result);
|
|
1068
|
+
void Promise.resolve(returned).catch(reportFailure);
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
reportFailure(error);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
|
1077
|
+
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
|
1078
|
+
* deployment that composes no ApprovalService keeps the historical degrade
|
|
1079
|
+
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
|
1080
|
+
* An agent-less execution also degrades: without an agent there is no
|
|
1081
|
+
* session to audit to and no UI to route to. Otherwise the outcome maps
|
|
1082
|
+
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
|
1083
|
+
* distinct reasons so the model can tell a human "no" from an absent
|
|
1084
|
+
* approval channel.
|
|
1085
|
+
*/
|
|
1086
|
+
async serviceAsk(exec, ask) {
|
|
1087
|
+
const approval = this.ctx.get('approval');
|
|
1088
|
+
if (approval === undefined) {
|
|
1089
|
+
return {
|
|
1090
|
+
decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
|
|
1091
|
+
approvalCancelled: false,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
if (exec.agent === undefined) {
|
|
1095
|
+
return {
|
|
1096
|
+
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
|
|
1097
|
+
approvalCancelled: false,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const outcome = await approval.request({
|
|
1101
|
+
agent: exec.agent,
|
|
1102
|
+
toolName: exec.name,
|
|
1103
|
+
callId: exec.callId,
|
|
1104
|
+
...ask.reason !== undefined ? { reason: ask.reason } : {},
|
|
1105
|
+
...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {},
|
|
1106
|
+
signal: exec.signal,
|
|
1107
|
+
});
|
|
1108
|
+
switch (outcome) {
|
|
1109
|
+
case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false };
|
|
1110
|
+
case 'rejected': return {
|
|
1111
|
+
decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
|
|
1112
|
+
approvalCancelled: false,
|
|
1113
|
+
};
|
|
1114
|
+
case 'cancelled': return {
|
|
1115
|
+
decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
|
|
1116
|
+
approvalCancelled: true,
|
|
1117
|
+
};
|
|
1118
|
+
case 'unavailable': return {
|
|
1119
|
+
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
|
|
1120
|
+
approvalCancelled: false,
|
|
1121
|
+
};
|
|
1122
|
+
default: return assertNever(outcome, 'ApprovalOutcome');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
|
1127
|
+
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
|
1128
|
+
* `content` when given), `block` turns it into an `isError` whose content is
|
|
1129
|
+
* the corrective `feedback`. Either decision may attach `additionalContexts`,
|
|
1130
|
+
* which are ferried on the returned result for the loop's active-batch FIFO.
|
|
1131
|
+
* Context deferred by the tool body survives an accepted result but is
|
|
1132
|
+
* discarded when the outer call is blocked; a block exposes only context the
|
|
1133
|
+
* blocking decision explicitly supplied.
|
|
1134
|
+
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
|
1135
|
+
*/
|
|
1136
|
+
async postExecute(exec, result) {
|
|
1137
|
+
const decision = await this.ctx.waterfall(scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }));
|
|
1138
|
+
const decisionContexts = decision.additionalContexts ?? [];
|
|
1139
|
+
if (decision.kind === 'block') {
|
|
1140
|
+
const message = failureMessageFromContent(decision.feedback);
|
|
1141
|
+
return this.markCanonical(exec, {
|
|
1142
|
+
content: decision.feedback,
|
|
1143
|
+
isError: true,
|
|
1144
|
+
error: { message },
|
|
1145
|
+
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
|
|
1149
|
+
throw new TypeError('tools/post-execute accept decision cannot replace both value and content');
|
|
1150
|
+
}
|
|
1151
|
+
const additionalContexts = [
|
|
1152
|
+
...result.additionalContexts ?? [],
|
|
1153
|
+
...decisionContexts,
|
|
1154
|
+
];
|
|
1155
|
+
if (Object.hasOwn(decision, 'value')) {
|
|
1156
|
+
if (result.isError) {
|
|
1157
|
+
throw new TypeError('tools/post-execute cannot replace the value of a failed result');
|
|
1158
|
+
}
|
|
1159
|
+
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
|
|
1160
|
+
if (tool === undefined)
|
|
1161
|
+
throw new ToolNotFoundError(exec.name);
|
|
1162
|
+
const replaced = this.createSuccessResult(exec, tool, decision.value);
|
|
1163
|
+
return this.markCanonical(exec, {
|
|
1164
|
+
...replaced,
|
|
1165
|
+
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
return this.markCanonical(exec, {
|
|
1169
|
+
...result,
|
|
1170
|
+
...decision.content !== undefined ? { content: decision.content } : {},
|
|
1171
|
+
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
/** Registry-normalized results and the exact dispatch that validated each value. */
|
|
1175
|
+
canonicalResults = new WeakMap();
|
|
1176
|
+
/** Mark one registry-normalized result as canonical only for its owning dispatch. */
|
|
1177
|
+
markCanonical(exec, result) {
|
|
1178
|
+
this.canonicalResults.set(result, exec.token);
|
|
1179
|
+
return result;
|
|
1180
|
+
}
|
|
1181
|
+
/** Snapshot, validate, render, and optionally project one successful body value. */
|
|
1182
|
+
createSuccessResult(exec, tool, candidate) {
|
|
1183
|
+
const detached = snapshotToolValue(tool.name, candidate);
|
|
1184
|
+
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value');
|
|
1185
|
+
if (violations.length > 0)
|
|
1186
|
+
throw new ToolOutputError(tool.name, violations);
|
|
1187
|
+
const value = deepFreeze(detached);
|
|
1188
|
+
let rendered;
|
|
1189
|
+
try {
|
|
1190
|
+
rendered = tool.output.render(exec.arguments, value);
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
throw projectionError(tool.name, 'render', error);
|
|
1194
|
+
}
|
|
1195
|
+
const content = snapshotProjection(tool.name, 'render', rendered);
|
|
1196
|
+
let meta;
|
|
1197
|
+
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
|
|
1198
|
+
let projected;
|
|
1199
|
+
try {
|
|
1200
|
+
projected = tool.output.presentationMeta(exec.arguments, value);
|
|
1201
|
+
}
|
|
1202
|
+
catch (error) {
|
|
1203
|
+
throw projectionError(tool.name, 'presentationMeta', error);
|
|
1204
|
+
}
|
|
1205
|
+
meta = snapshotProjection(tool.name, 'presentationMeta', projected);
|
|
1206
|
+
}
|
|
1207
|
+
const concludesTurn = this.concludingExecutions.has(exec);
|
|
1208
|
+
return this.markCanonical(exec, this.materializeFinalResult({
|
|
1209
|
+
isError: false,
|
|
1210
|
+
value,
|
|
1211
|
+
content,
|
|
1212
|
+
...meta !== undefined ? { meta } : {},
|
|
1213
|
+
...concludesTurn ? { concludesTurn: true } : {},
|
|
1214
|
+
}));
|
|
1215
|
+
}
|
|
1216
|
+
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
|
|
1217
|
+
normalizeDispatchResult(exec, result) {
|
|
1218
|
+
if (this.canonicalResults.get(result) === exec.token)
|
|
1219
|
+
return result;
|
|
1220
|
+
if (result.isError) {
|
|
1221
|
+
return this.markCanonical(exec, {
|
|
1222
|
+
isError: true,
|
|
1223
|
+
error: result.error,
|
|
1224
|
+
content: result.content,
|
|
1225
|
+
...result.meta !== undefined ? { meta: result.meta } : {},
|
|
1226
|
+
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined);
|
|
1230
|
+
if (tool === undefined)
|
|
1231
|
+
throw new ToolNotFoundError(exec.name);
|
|
1232
|
+
const normalized = this.createSuccessResult(exec, tool, result.value);
|
|
1233
|
+
return this.markCanonical(exec, {
|
|
1234
|
+
...normalized,
|
|
1235
|
+
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
|
1239
|
+
materializeFinalResult(result) {
|
|
1240
|
+
const presentation = {
|
|
1241
|
+
content: result.content,
|
|
1242
|
+
...result.meta !== undefined ? { meta: result.meta } : {},
|
|
1243
|
+
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
1244
|
+
};
|
|
1245
|
+
if (result.isError) {
|
|
1246
|
+
return materializePresentation({ isError: true, error: result.error, ...presentation });
|
|
1247
|
+
}
|
|
1248
|
+
const detached = materializePresentation({
|
|
1249
|
+
isError: false,
|
|
1250
|
+
...presentation,
|
|
1251
|
+
...result.concludesTurn === true ? { concludesTurn: true } : {},
|
|
1252
|
+
});
|
|
1253
|
+
return deepFreeze({ ...detached, value: result.value });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/** Mint a same-process correlation token whose identity is its value. */
|
|
1257
|
+
function createExecutionToken() {
|
|
1258
|
+
return Symbol('qilin.tool.execution');
|
|
1259
|
+
}
|
|
1260
|
+
function toolErrorResult(error) {
|
|
1261
|
+
const info = errorInfo(error);
|
|
1262
|
+
const message = errorMessage(error);
|
|
1263
|
+
return {
|
|
1264
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
1265
|
+
isError: true,
|
|
1266
|
+
error: { message, ...info ? { info } : {} },
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
/** Read live abort state across an await without treating it as synchronously immutable. */
|
|
1270
|
+
function isAborted(signal) {
|
|
1271
|
+
return signal.aborted;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
|
|
1275
|
+
* Keeping the relay dispatch-scoped also removes listeners when work settles.
|
|
1276
|
+
*/
|
|
1277
|
+
function fuseToolSignals(caller, wrapper) {
|
|
1278
|
+
if (caller === wrapper)
|
|
1279
|
+
return { signal: caller, dispose() { } };
|
|
1280
|
+
const controller = new AbortController();
|
|
1281
|
+
let listening = false;
|
|
1282
|
+
const dispose = () => {
|
|
1283
|
+
if (!listening)
|
|
1284
|
+
return;
|
|
1285
|
+
listening = false;
|
|
1286
|
+
caller.removeEventListener('abort', abortFromCaller);
|
|
1287
|
+
wrapper.removeEventListener('abort', abortFromWrapper);
|
|
1288
|
+
};
|
|
1289
|
+
const abortFrom = (source) => {
|
|
1290
|
+
const reason = source.reason;
|
|
1291
|
+
controller.abort(reason);
|
|
1292
|
+
dispose();
|
|
1293
|
+
};
|
|
1294
|
+
const abortFromCaller = () => { abortFrom(caller); };
|
|
1295
|
+
const abortFromWrapper = () => { abortFrom(wrapper); };
|
|
1296
|
+
if (wrapper.aborted)
|
|
1297
|
+
abortFromWrapper();
|
|
1298
|
+
else if (caller.aborted)
|
|
1299
|
+
abortFromCaller();
|
|
1300
|
+
else {
|
|
1301
|
+
listening = true;
|
|
1302
|
+
caller.addEventListener('abort', abortFromCaller, { once: true });
|
|
1303
|
+
wrapper.addEventListener('abort', abortFromWrapper, { once: true });
|
|
1304
|
+
}
|
|
1305
|
+
return { signal: controller.signal, dispose };
|
|
1306
|
+
}
|
|
1307
|
+
/** Canonical result when cancellation supersedes success after body invocation. */
|
|
1308
|
+
function toolAbortedResult(prior) {
|
|
1309
|
+
const additionalContexts = prior?.additionalContexts ?? [];
|
|
1310
|
+
return {
|
|
1311
|
+
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
|
1312
|
+
isError: true,
|
|
1313
|
+
error: {
|
|
1314
|
+
message: 'tool call aborted',
|
|
1315
|
+
info: { name: 'AbortError', code: TOOL_ABORTED },
|
|
1316
|
+
},
|
|
1317
|
+
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
/** Canonical result when cancellation prevents tool body invocation. */
|
|
1321
|
+
function toolAbortedBeforeDispatchResult(prior) {
|
|
1322
|
+
const additionalContexts = prior?.additionalContexts ?? [];
|
|
1323
|
+
return {
|
|
1324
|
+
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
|
1325
|
+
isError: true,
|
|
1326
|
+
error: {
|
|
1327
|
+
message: 'tool call aborted before dispatch',
|
|
1328
|
+
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
|
1329
|
+
},
|
|
1330
|
+
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
export default ToolRuntime;
|
|
1334
|
+
//# sourceMappingURL=index.js.map
|