@superdoc/sdk 2.11.0-next.3 → 2.11.0-next.5

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,781 @@
1
+ /**
2
+ * Customer-extensible **custom actions** for the SuperDoc LLM-tools SDK. The
3
+ * canonical ActionSpec has exactly ONE execution tier:
4
+ *
5
+ * - `steps` — declarative composition of built-in core actions with
6
+ * {{arg}} templating; dispatches through the base preset and
7
+ * inherits its target resolution, receipts, and verification.
8
+ * - `run` — a native function executed in the CALLER'S process against
9
+ * the typed session-bound doc handle, with synthesized
10
+ * truth-telling receipts (pre/post revision, partialMutation).
11
+ *
12
+ * NOTE: a third, IN-HOST tier — a JS function-expression run inside the
13
+ * document host via `superdoc_execute_code` — is intentionally not part of the
14
+ * kit yet. It lands together with the code-act execution path (and its safety
15
+ * envelope); until then the kit exposes only `steps` and `run`.
16
+ *
17
+ * `extendPreset` merges custom actions into the
18
+ * superdoc_perform_action enum, tool description, system prompt, and dispatch
19
+ * COHERENTLY — including excludeActions, which may name built-in actions
20
+ * (forwarded to the base) or custom ones (handled by the wrapper). No CLI
21
+ * host changes are required by either tier.
22
+ *
23
+ * Cross-runtime contract: templating semantics, input-schema defaults, and
24
+ * receipt shapes are identical to the Python mirror
25
+ * (`langs/python/superdoc/presets/custom.py`) for any JSON-serializable tool
26
+ * input. (NaN/Infinity/lone-surrogates are out of scope: they cannot appear in
27
+ * a JSON tool call.)
28
+ *
29
+ * @module
30
+ */
31
+ import { SuperDocCliError } from '../runtime/errors.js';
32
+ import { getPreset, } from '../presets.js';
33
+ import { ACTION_NAMES_LIST } from '../agent/actions.js';
34
+ import { AGENT_TOOL_NAMES, buildPerformActionDefinition } from '../agent/catalog.js';
35
+ /** Which execution tier a spec uses. */
36
+ export function executionKindOf(spec) {
37
+ return Array.isArray(spec.steps) ? 'steps' : 'run';
38
+ }
39
+ const BUILTIN_ACTION_NAMES = new Set(ACTION_NAMES_LIST);
40
+ function isJsonSchemaObject(value) {
41
+ return (value != null &&
42
+ typeof value === 'object' &&
43
+ !Array.isArray(value) &&
44
+ value.type === 'object');
45
+ }
46
+ /**
47
+ * Normalize the `input` field to a JSON Schema object. We take NO schema-library
48
+ * dependency: a Zod (or Zod-like) schema is rejected with a clear, actionable
49
+ * error instead of being silently accepted with its types dropped — the caller
50
+ * converts it first (e.g. `zod-to-json-schema`). A plain JSON Schema object is
51
+ * the primary path; a bare properties bag is wrapped for convenience.
52
+ */
53
+ function coerceInputSchema(input) {
54
+ if (input == null) {
55
+ return { type: 'object', properties: {}, additionalProperties: true };
56
+ }
57
+ // Zod / Zod-like schemas expose `_def` (and usually `.parse`/`.safeParse`).
58
+ // Reject clearly rather than half-supporting them (dropping constraints).
59
+ // Checked BEFORE isJsonSchemaObject so a library schema can never slip through.
60
+ const duck = input;
61
+ if (duck._def != null || typeof duck.parse === 'function' || typeof duck.safeParse === 'function') {
62
+ throw new SuperDocCliError('defineAction `input` must be a JSON Schema object, not a Zod (or other library) schema. ' +
63
+ 'Convert it first, e.g. `import { zodToJsonSchema } from "zod-to-json-schema"; ' +
64
+ 'defineAction({ input: zodToJsonSchema(mySchema), ... })`.', { code: 'INVALID_ARGUMENT' });
65
+ }
66
+ if (isJsonSchemaObject(input)) {
67
+ // A valid object schema may legally omit `properties` (an open schema like
68
+ // `{ type: 'object', additionalProperties: true }`). The declared type makes
69
+ // `properties` required and every consumer (and the Python mirror) assumes
70
+ // it exists, so normalize a missing/non-object bag to {}.
71
+ return isRecord(input.properties) ? input : { ...input, properties: {} };
72
+ }
73
+ // A bare properties bag — wrap it.
74
+ return { type: 'object', properties: input, additionalProperties: true };
75
+ }
76
+ /**
77
+ * Author a {@link ActionSpec}. Pass exactly one execution tier: `steps`
78
+ * (declarative — recommended) or `run` (native function in your process).
79
+ */
80
+ export function defineAction(input) {
81
+ if (typeof input.name !== 'string' || input.name.length === 0) {
82
+ throw new SuperDocCliError('defineAction requires a non-empty `name`.', {
83
+ code: 'INVALID_ARGUMENT',
84
+ details: { input: 'name' },
85
+ });
86
+ }
87
+ if (typeof input.description !== 'string') {
88
+ throw new SuperDocCliError(`defineAction "${input.name}" requires a string \`description\`.`, {
89
+ code: 'INVALID_ARGUMENT',
90
+ details: { name: input.name },
91
+ });
92
+ }
93
+ const tiers = [];
94
+ if (Array.isArray(input.steps))
95
+ tiers.push('steps');
96
+ if (typeof input.run === 'function')
97
+ tiers.push('run');
98
+ if (tiers.length !== 1) {
99
+ throw new SuperDocCliError(`defineAction "${input.name}" requires exactly one of \`steps\` or \`run\` (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`, { code: 'INVALID_ARGUMENT', details: { name: input.name, tiers } });
100
+ }
101
+ const spec = {
102
+ name: input.name,
103
+ description: input.description,
104
+ inputSchema: coerceInputSchema(input.input),
105
+ };
106
+ if (tiers[0] === 'steps') {
107
+ const steps = input.steps;
108
+ if (steps.length === 0) {
109
+ throw new SuperDocCliError(`defineAction "${input.name}": \`steps\` must be a non-empty array.`, {
110
+ code: 'INVALID_ARGUMENT',
111
+ details: { name: input.name },
112
+ });
113
+ }
114
+ steps.forEach((step, index) => {
115
+ if (step == null || typeof step.action !== 'string' || step.action.length === 0) {
116
+ throw new SuperDocCliError(`defineAction "${input.name}": steps[${index}] needs a non-empty \`action\` string.`, { code: 'INVALID_ARGUMENT', details: { name: input.name, index } });
117
+ }
118
+ if (!BUILTIN_ACTION_NAMES.has(step.action)) {
119
+ throw new SuperDocCliError(`defineAction "${input.name}": steps[${index}].action "${step.action}" is not a built-in core action. Steps compose built-in actions only; use the \`run\` tier for anything else.`, { code: 'INVALID_ARGUMENT', details: { name: input.name, index, action: step.action } });
120
+ }
121
+ if (step.args != null && !isRecord(step.args)) {
122
+ throw new SuperDocCliError(`defineAction "${input.name}": steps[${index}].args must be an object.`, {
123
+ code: 'INVALID_ARGUMENT',
124
+ details: { name: input.name, index },
125
+ });
126
+ }
127
+ });
128
+ spec.steps = steps.map((step) => ({ action: step.action, args: { ...step.args } }));
129
+ }
130
+ else {
131
+ spec.run = input.run;
132
+ }
133
+ return spec;
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Codegen — identical to the Python mirror for JSON-serializable tool inputs
137
+ // (the only values a tool call can carry; NaN/Infinity/lone-surrogates are out
138
+ // of scope and serialize differently across Node and Python).
139
+ // ---------------------------------------------------------------------------
140
+ // Collision / duplicate validation
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * Provider tool-name rule. OpenAI/Anthropic require tool names to match
144
+ * `^[A-Za-z0-9_-]{1,64}$` — dots are invalid. This only matters in STANDALONE
145
+ * mode, where the action name becomes a tool name; in MERGED mode the name is
146
+ * an enum VALUE (dots are fine).
147
+ */
148
+ const PROVIDER_SAFE_TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
149
+ /** Tool names of the agent surface itself — a custom action must never shadow one. */
150
+ const RESERVED_TOOL_NAMES = new Set(AGENT_TOOL_NAMES);
151
+ /**
152
+ * excludeActions may name BUILT-IN
153
+ * actions (forwarded to the base, which validates them) or CUSTOM actions
154
+ * (handled by the wrapper — the base would reject names it doesn't know).
155
+ * Both halves narrow tools, prompt, and dispatch together, preserving the
156
+ * kit's coherence guarantee.
157
+ */
158
+ function splitCustomExclusions(byName, list) {
159
+ if (!list || list.length === 0)
160
+ return { customExcluded: new Set(), builtinExcluded: undefined };
161
+ const customExcluded = new Set();
162
+ const builtin = [];
163
+ for (const name of list) {
164
+ if (byName.has(name))
165
+ customExcluded.add(name);
166
+ else
167
+ builtin.push(name);
168
+ }
169
+ return { customExcluded, builtinExcluded: builtin.length > 0 ? builtin : undefined };
170
+ }
171
+ /** Defense-in-depth refusal shared by both wrappers' dispatchers. */
172
+ function throwExcludedAction(toolName, actionName, extra = {}) {
173
+ throw new SuperDocCliError(`Action ${actionName} is excluded by configuration.`, {
174
+ code: 'INVALID_ARGUMENT',
175
+ details: { toolName, action: actionName, excluded: true, ...extra },
176
+ });
177
+ }
178
+ /**
179
+ * Drop surface `excludeActions` from invoke options before INTERNAL step
180
+ * dispatch. Those exclusions govern what the MODEL may call directly (the
181
+ * top-level dispatch already enforced them); a steps-tier custom action is an
182
+ * authored composition whose steps must run even when a built-in they compose
183
+ * is hidden from the model. Everything else in invokeOptions passes through.
184
+ */
185
+ function stripSurfaceExclusions(invokeOptions) {
186
+ if (!invokeOptions || !('excludeActions' in invokeOptions))
187
+ return invokeOptions;
188
+ const { excludeActions: _dropped, ...rest } = invokeOptions;
189
+ return rest;
190
+ }
191
+ function assertActionsValid(actions, presetId, standalone = false) {
192
+ const seen = new Set();
193
+ for (const action of actions) {
194
+ // Raw spec objects can bypass defineAction — re-validate the tier shape
195
+ // here so a hand-rolled {steps: []} can't fabricate succeeded receipts.
196
+ const tiers = [
197
+ Array.isArray(action.steps) ? 'steps' : null,
198
+ typeof action.run === 'function' ? 'run' : null,
199
+ ].filter((tier) => tier != null);
200
+ if (tiers.length !== 1) {
201
+ throw new SuperDocCliError(`Custom action "${action.name}" must have exactly one of steps/run (got ${tiers.length === 0 ? 'none' : tiers.join(' + ')}).`, { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, tiers } });
202
+ }
203
+ if (Array.isArray(action.steps)) {
204
+ if (action.steps.length === 0) {
205
+ throw new SuperDocCliError(`Custom action "${action.name}": steps must be non-empty.`, {
206
+ code: 'INVALID_ARGUMENT',
207
+ details: { presetId, name: action.name },
208
+ });
209
+ }
210
+ for (const [index, step] of action.steps.entries()) {
211
+ if (!step || typeof step.action !== 'string' || !BUILTIN_ACTION_NAMES.has(step.action)) {
212
+ throw new SuperDocCliError(`Custom action "${action.name}": steps[${index}].action must be a built-in core action.`, { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name, index } });
213
+ }
214
+ }
215
+ }
216
+ if (BUILTIN_ACTION_NAMES.has(action.name)) {
217
+ throw new SuperDocCliError(`Custom action "${action.name}" collides with a built-in core action name. Use a namespaced name like "superdoc.${action.name}".`, { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } });
218
+ }
219
+ if (RESERVED_TOOL_NAMES.has(action.name)) {
220
+ throw new SuperDocCliError(`Custom action "${action.name}" collides with a reserved tool name — it would shadow the agent surface itself.`, { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } });
221
+ }
222
+ if (seen.has(action.name)) {
223
+ throw new SuperDocCliError(`Duplicate custom action name "${action.name}" in preset "${presetId}".`, {
224
+ code: 'INVALID_ARGUMENT',
225
+ details: { presetId, name: action.name },
226
+ });
227
+ }
228
+ // In standalone mode the action name becomes a provider tool name, which
229
+ // OpenAI/Anthropic reject unless it matches ^[A-Za-z0-9_-]{1,64}$ (dotted
230
+ // namespaced names are only valid as merged enum VALUES).
231
+ if (standalone && !PROVIDER_SAFE_TOOL_NAME.test(action.name)) {
232
+ throw new SuperDocCliError(`standalone action names must match ^[A-Za-z0-9_-]{1,64}$; "${action.name}" has invalid characters — use merged mode for dotted names.`, { code: 'INVALID_ARGUMENT', details: { presetId, name: action.name } });
233
+ }
234
+ seen.add(action.name);
235
+ }
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // runCustomAction — validate, codegen, dispatch via superdoc_execute_code, map receipt
239
+ // ---------------------------------------------------------------------------
240
+ function isRecord(value) {
241
+ return value != null && typeof value === 'object' && !Array.isArray(value);
242
+ }
243
+ /** Kit-level args every custom action accepts without declaring them. */
244
+ const IMPLICIT_ACTION_ARGS = new Set(['changeMode', 'rationale']);
245
+ function validateAgainstSchema(action, args) {
246
+ const required = Array.isArray(action.inputSchema.required) ? action.inputSchema.required : [];
247
+ const missing = required.filter((key) => args[key] == null);
248
+ if (missing.length > 0) {
249
+ throw new SuperDocCliError(`Missing required argument(s) for ${action.name}: ${missing.join(', ')}`, {
250
+ code: 'INVALID_ARGUMENT',
251
+ details: { action: action.name, missingKeys: missing },
252
+ });
253
+ }
254
+ const properties = isRecord(action.inputSchema.properties) ? action.inputSchema.properties : {};
255
+ if (action.inputSchema.additionalProperties === false) {
256
+ const unknown = Object.keys(args).filter((key) => !(key in properties) && !IMPLICIT_ACTION_ARGS.has(key));
257
+ if (unknown.length > 0) {
258
+ throw new SuperDocCliError(`Unknown argument(s) for ${action.name}: ${unknown.join(', ')}`, {
259
+ code: 'INVALID_ARGUMENT',
260
+ details: { action: action.name, unknownKeys: unknown, knownKeys: Object.keys(properties) },
261
+ });
262
+ }
263
+ }
264
+ for (const [key, prop] of Object.entries(properties)) {
265
+ const value = args[key];
266
+ if (value !== undefined && isRecord(prop) && Array.isArray(prop.enum) && !prop.enum.includes(value)) {
267
+ throw new SuperDocCliError(`Invalid value for ${action.name}.${key}: ${JSON.stringify(value)} (allowed: ${prop.enum.map((entry) => JSON.stringify(entry)).join(', ')})`, { code: 'INVALID_ARGUMENT', details: { action: action.name, key, allowed: prop.enum } });
268
+ }
269
+ }
270
+ }
271
+ /** Fill in `inputSchema.properties.*.default` values for absent args. */
272
+ function applyInputDefaults(action, args) {
273
+ const out = { ...args };
274
+ for (const [key, prop] of Object.entries(action.inputSchema.properties ?? {})) {
275
+ if (out[key] === undefined && isRecord(prop) && 'default' in prop)
276
+ out[key] = prop.default;
277
+ }
278
+ return out;
279
+ }
280
+ const WHOLE_TEMPLATE = /^\{\{(\w+)\}\}$/;
281
+ /**
282
+ * Substitute `{{arg}}` templates in a step's args. A whole-string `"{{x}}"`
283
+ * yields the RAW value (arrays/objects/numbers survive); partial templates
284
+ * interpolate as text. Keys whose whole-string template resolves to undefined
285
+ * are dropped, so optional args don't inject `undefined` into step args.
286
+ */
287
+ function substituteTemplates(node, vars) {
288
+ if (typeof node === 'string') {
289
+ const whole = WHOLE_TEMPLATE.exec(node);
290
+ if (whole)
291
+ return vars[whole[1]];
292
+ return node.replace(/\{\{(\w+)\}\}/g, (_, name) => {
293
+ const value = vars[name];
294
+ // Text interpolation must be byte-identical across Node and Python:
295
+ // strings verbatim, null/undefined → '', everything else compact JSON
296
+ // (true/[1,2]/{"a":1} — NOT String(), whose array/object/boolean forms
297
+ // differ from Python's str()).
298
+ if (value == null)
299
+ return '';
300
+ return typeof value === 'string' ? value : JSON.stringify(value);
301
+ });
302
+ }
303
+ if (Array.isArray(node)) {
304
+ // Whole-string templates for ABSENT args are dropped from arrays too —
305
+ // leaving undefined behind would serialize as null (and Python's sentinel
306
+ // would crash the transport).
307
+ return node.map((item) => substituteTemplates(item, vars)).filter((item) => item !== undefined);
308
+ }
309
+ if (isRecord(node)) {
310
+ const out = {};
311
+ for (const [key, value] of Object.entries(node)) {
312
+ const substituted = substituteTemplates(value, vars);
313
+ if (substituted !== undefined)
314
+ out[key] = substituted;
315
+ }
316
+ return out;
317
+ }
318
+ return node;
319
+ }
320
+ /**
321
+ * `steps` tier — dispatch each built-in step through the base preset and
322
+ * aggregate per-step receipts. Stops at the first failed step; the aggregate
323
+ * status is `partial` when earlier steps landed, `failed` otherwise.
324
+ */
325
+ async function runStepsAction(base, action, documentHandle, args, invokeOptions) {
326
+ const vars = args; // defaults already applied by the router
327
+ const rows = [];
328
+ // Steps are the author's curated composition, not model calls — surface
329
+ // exclusions must not refuse a built-in the action deliberately composes.
330
+ const stepOptions = stripSurfaceExclusions(invokeOptions);
331
+ for (const [index, step] of (action.steps ?? []).entries()) {
332
+ let stepArgs = substituteTemplates(step.args ?? {}, vars);
333
+ // changeMode pass-through: a caller-level changeMode reaches every step
334
+ // that doesn't pin its own.
335
+ if (typeof vars.changeMode === 'string' && stepArgs.changeMode === undefined) {
336
+ stepArgs = { ...stepArgs, changeMode: vars.changeMode };
337
+ }
338
+ let receipt;
339
+ try {
340
+ const dispatched = await base.dispatch(documentHandle, 'superdoc_perform_action', { action: step.action, ...stepArgs }, stepOptions);
341
+ receipt = isRecord(dispatched) ? dispatched : { status: 'ok', result: dispatched };
342
+ }
343
+ catch (error) {
344
+ // Validation errors THROW from dispatch; runtime failures come back as
345
+ // failed receipts. Normalize both into the per-step row.
346
+ const err = error;
347
+ receipt = { status: 'failed', errors: [{ code: err.code ?? null, message: err.message ?? String(error) }] };
348
+ }
349
+ rows.push({
350
+ step: index,
351
+ action: step.action,
352
+ status: receipt.status,
353
+ ...(receipt.verificationPassed !== undefined ? { verificationPassed: receipt.verificationPassed } : {}),
354
+ });
355
+ if (receipt.status === 'failed') {
356
+ const anyLanded = rows.some((row) => row.status !== 'failed');
357
+ return {
358
+ status: anyLanded ? 'partial' : 'failed',
359
+ action: action.name,
360
+ steps: rows,
361
+ failedStep: { index, receipt },
362
+ };
363
+ }
364
+ // Truthfulness: a step that only PARTIALLY landed (or whose verification
365
+ // disagreed) must not roll up into a clean `succeeded`. Stop and report
366
+ // partial with the evidence — later steps may depend on the missing part.
367
+ if (receipt.status === 'partial' || receipt.verificationPassed === false) {
368
+ return {
369
+ status: 'partial',
370
+ action: action.name,
371
+ steps: rows,
372
+ failedStep: { index, receipt },
373
+ };
374
+ }
375
+ }
376
+ return { status: 'succeeded', action: action.name, steps: rows };
377
+ }
378
+ /** Read the session revision off a client-side doc handle, if it can. */
379
+ async function readRevision(documentHandle) {
380
+ const info = documentHandle.info;
381
+ if (typeof info !== 'function')
382
+ return null;
383
+ try {
384
+ const result = (await info.call(documentHandle, {}));
385
+ return result?.revision == null ? null : String(result.revision);
386
+ }
387
+ catch {
388
+ return null;
389
+ }
390
+ }
391
+ /**
392
+ * `run` tier — execute the native function in the caller's process against the
393
+ * typed doc handle, synthesizing a truth-telling receipt: pre/post revision,
394
+ * and on failure whether a partial mutation was left behind + a recovery hint.
395
+ */
396
+ async function runNativeAction(action, documentHandle, args) {
397
+ const vars = args; // defaults already applied by the router
398
+ const preRevision = await readRevision(documentHandle);
399
+ try {
400
+ const result = await action.run(documentHandle, vars);
401
+ const postRevision = await readRevision(documentHandle);
402
+ return { status: 'succeeded', action: action.name, result, preRevision, postRevision };
403
+ }
404
+ catch (error) {
405
+ const err = error;
406
+ const postRevision = await readRevision(documentHandle);
407
+ const partialMutation = preRevision != null && postRevision != null && preRevision !== postRevision;
408
+ return {
409
+ status: 'failed',
410
+ action: action.name,
411
+ errors: [{ code: err.code ?? null, message: err.message ?? String(error) }],
412
+ preRevision,
413
+ postRevision,
414
+ partialMutation,
415
+ recovery: partialMutation
416
+ ? { kind: 'revert', call: 'superdoc_perform_action {action:"undo_changes"}' }
417
+ : { kind: 'retry' },
418
+ };
419
+ }
420
+ }
421
+ /** Validate args, then route the custom action to its execution tier. */
422
+ async function runCustomAction(base, action, documentHandle, rawArgs, invokeOptions, fromPerformAction = true) {
423
+ // `action` is the superdoc_perform_action discriminator ONLY on that route;
424
+ // in standalone mode the action is its own tool, so `action` may be a real
425
+ // declared argument — strip it only when it came in as the discriminator.
426
+ const rawRest = { ...rawArgs };
427
+ if (fromPerformAction)
428
+ delete rawRest.action;
429
+ // Apply schema defaults, THEN validate — a required arg with a declared
430
+ // default is satisfiable by the default.
431
+ const args = applyInputDefaults(action, rawRest);
432
+ validateAgainstSchema(action, args);
433
+ return executionKindOf(action) === 'steps'
434
+ ? runStepsAction(base, action, documentHandle, args, invokeOptions)
435
+ : runNativeAction(action, documentHandle, args);
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Tool-list merging — mirror the provider shapes from agent/catalog.ts
439
+ // ---------------------------------------------------------------------------
440
+ function toolNameOf(tool) {
441
+ const t = tool;
442
+ return t?.function?.name ?? t?.name ?? '';
443
+ }
444
+ /**
445
+ * Re-apply the Anthropic prompt-cache marker after the tool list was mutated.
446
+ *
447
+ * The base preset places `cache_control: { type: 'ephemeral' }` on its LAST
448
+ * tool. Appending (standalone) or narrowing tools (includeActions) can leave the
449
+ * marker mid-list or drop it entirely. When the marker is meaningful — provider
450
+ * is anthropic and cache was requested — strip any existing `cache_control` and
451
+ * put it back on the final last tool so the cached prefix stays correct. No-op
452
+ * for other providers / when cache was not requested / on an empty list.
453
+ */
454
+ function renormalizeAnthropicCacheMarker(tools, provider, cacheRequested) {
455
+ if (provider !== 'anthropic' || !cacheRequested || tools.length === 0)
456
+ return tools;
457
+ const stripped = tools.map((tool) => {
458
+ if (!isRecord(tool) || !('cache_control' in tool))
459
+ return tool;
460
+ const { cache_control: _drop, ...rest } = tool;
461
+ return rest;
462
+ });
463
+ const last = stripped[stripped.length - 1];
464
+ stripped[stripped.length - 1] = isRecord(last) ? { ...last, cache_control: { type: 'ephemeral' } } : last;
465
+ return stripped;
466
+ }
467
+ function customActionsDescription(actions) {
468
+ return ` Custom actions: ${actions.map((r) => `${r.name} (${r.description})`).join('; ')}.`;
469
+ }
470
+ /**
471
+ * Merge custom actions into the existing `superdoc_perform_action` tool: append names to
472
+ * the `action` enum, union inputSchema.properties into the tool's properties,
473
+ * and extend the description.
474
+ */
475
+ /**
476
+ * Reject a custom arg whose name collides with an existing arg (built-in or an
477
+ * earlier custom action) of a DIFFERENT shape. Merging into one flat
478
+ * `superdoc_perform_action` schema means a single name → one schema; silently
479
+ * keeping the first would advertise one shape for two meanings. Identical
480
+ * re-declarations (same JSON Schema) are allowed — actions may share an arg.
481
+ */
482
+ /** Order-insensitive JSON serialization: object keys sorted recursively (array
483
+ * order preserved — it is semantic for `enum`/`required`). So two schemas that
484
+ * differ ONLY in key order compare equal. */
485
+ function canonicalJson(value) {
486
+ if (value === null || typeof value !== 'object')
487
+ return JSON.stringify(value) ?? 'null';
488
+ if (Array.isArray(value))
489
+ return `[${value.map(canonicalJson).join(',')}]`;
490
+ const obj = value;
491
+ const body = Object.keys(obj)
492
+ .sort()
493
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
494
+ .join(',');
495
+ return `{${body}}`;
496
+ }
497
+ /** Documentation-only JSON Schema keywords — differences here never conflict. */
498
+ const METADATA_SCHEMA_KEYS = new Set(['description', 'title', 'examples', '$comment']);
499
+ /** Drop doc-only keys (recursively) so comparison sees just the structural shape. */
500
+ function structuralSchema(value) {
501
+ if (value === null || typeof value !== 'object')
502
+ return value;
503
+ if (Array.isArray(value))
504
+ return value.map(structuralSchema);
505
+ const out = {};
506
+ for (const [k, v] of Object.entries(value)) {
507
+ if (METADATA_SCHEMA_KEYS.has(k))
508
+ continue;
509
+ out[k] = structuralSchema(v);
510
+ }
511
+ return out;
512
+ }
513
+ /** Two arg schemas CONFLICT when they differ in any way EXCEPT documentation
514
+ * (description/title/examples/$comment): reusing a built-in arg name with your
515
+ * own description is fine, but a different type, enum (incl. one-sided),
516
+ * default, limit, pattern, or nested shape is a real conflict — the merged
517
+ * surface advertises ONE schema per name. */
518
+ function argSchemasConflict(a, b) {
519
+ return canonicalJson(structuralSchema(a)) !== canonicalJson(structuralSchema(b));
520
+ }
521
+ function assertNoArgConflict(properties, key, value, actionName) {
522
+ if (key in properties && argSchemasConflict(properties[key], value)) {
523
+ throw new SuperDocCliError(`Custom action "${actionName}" declares argument "${key}" with a schema that conflicts with an existing ` +
524
+ `argument of the same name on the superdoc_perform_action surface (they differ beyond description). Rename the argument, or match the existing schema exactly.`, { code: 'INVALID_ARGUMENT', details: { action: actionName, arg: key } });
525
+ }
526
+ }
527
+ function mergeIntoAgentAction(tools, actions) {
528
+ return tools.map((tool) => {
529
+ if (toolNameOf(tool) !== 'superdoc_perform_action')
530
+ return tool;
531
+ const t = tool;
532
+ // Provider dialects: openai nests under `function` (`parameters`);
533
+ // anthropic is flat with `input_schema`; the core agent dialect for
534
+ // vercel is flat with `inputSchema`; generic is flat with `parameters`.
535
+ const fn = isRecord(t.function) ? t.function : null;
536
+ const schemaContainer = fn ?? t;
537
+ const schemaKey = 'input_schema' in schemaContainer
538
+ ? 'input_schema'
539
+ : 'inputSchema' in schemaContainer
540
+ ? 'inputSchema'
541
+ : 'parameters';
542
+ const schema = isRecord(schemaContainer[schemaKey]) ? schemaContainer[schemaKey] : {};
543
+ const properties = isRecord(schema.properties) ? { ...schema.properties } : {};
544
+ const actionProp = isRecord(properties.action) ? { ...properties.action } : {};
545
+ const enumValues = Array.isArray(actionProp.enum) ? [...actionProp.enum] : [];
546
+ for (const action of actions) {
547
+ if (!enumValues.includes(action.name))
548
+ enumValues.push(action.name);
549
+ for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) {
550
+ assertNoArgConflict(properties, key, value, action.name);
551
+ if (!(key in properties))
552
+ properties[key] = value;
553
+ }
554
+ }
555
+ const nextActionProp = { ...actionProp, enum: enumValues };
556
+ const nextSchema = { ...schema, properties: { ...properties, action: nextActionProp } };
557
+ const baseDescription = typeof schemaContainer.description === 'string' ? schemaContainer.description : '';
558
+ const nextDescription = baseDescription + customActionsDescription(actions);
559
+ const nextContainer = { ...schemaContainer, description: nextDescription, [schemaKey]: nextSchema };
560
+ return fn ? { ...t, function: nextContainer } : nextContainer;
561
+ });
562
+ }
563
+ /**
564
+ * When the base dropped `superdoc_perform_action` entirely (all built-ins
565
+ * excluded / an empty allowlist), active custom actions would be advertised in
566
+ * the prompt and dispatchable — but carried by NO tool. Synthesize a
567
+ * custom-only definition so a curated custom-only preset stays callable.
568
+ */
569
+ function synthesizePerformAction(provider, actions) {
570
+ const properties = {
571
+ action: { type: 'string', enum: actions.map((action) => action.name) },
572
+ };
573
+ for (const action of actions) {
574
+ for (const [key, value] of Object.entries(action.inputSchema.properties ?? {})) {
575
+ assertNoArgConflict(properties, key, value, action.name);
576
+ if (!(key in properties))
577
+ properties[key] = value;
578
+ }
579
+ }
580
+ const schema = { type: 'object', additionalProperties: true, required: ['action'], properties };
581
+ const description = "Perform one of this preset's custom document actions. Pick an action and pass its flat arguments." +
582
+ customActionsDescription(actions);
583
+ if (provider === 'anthropic') {
584
+ return { name: 'superdoc_perform_action', description, input_schema: schema };
585
+ }
586
+ if (provider === 'vercel') {
587
+ return { name: 'superdoc_perform_action', description, inputSchema: schema };
588
+ }
589
+ if (provider === 'openai') {
590
+ return { type: 'function', function: { name: 'superdoc_perform_action', description, parameters: schema } };
591
+ }
592
+ return { name: 'superdoc_perform_action', description, parameters: schema };
593
+ }
594
+ /** Merge into an existing perform_action tool, or synthesize one if the base dropped it. */
595
+ function mergeOrSynthesizePerformAction(tools, actions, provider) {
596
+ const hasPerformAction = tools.some((tool) => toolNameOf(tool) === 'superdoc_perform_action');
597
+ if (hasPerformAction)
598
+ return mergeIntoAgentAction(tools, actions);
599
+ return [...tools, synthesizePerformAction(provider, actions)];
600
+ }
601
+ /** Build a single provider-shaped standalone tool for a custom action. */
602
+ function standaloneTool(provider, action) {
603
+ if (provider === 'anthropic') {
604
+ return { name: action.name, description: action.description, input_schema: action.inputSchema };
605
+ }
606
+ // The core agent dialect for vercel is FLAT {name, description, inputSchema}
607
+ // (agent/catalog.ts toVercelTool) — not the OpenAI nested function shape.
608
+ if (provider === 'vercel') {
609
+ return { name: action.name, description: action.description, inputSchema: action.inputSchema };
610
+ }
611
+ if (provider === 'openai') {
612
+ return {
613
+ type: 'function',
614
+ function: { name: action.name, description: action.description, parameters: action.inputSchema },
615
+ };
616
+ }
617
+ // generic
618
+ return { name: action.name, description: action.description, parameters: action.inputSchema };
619
+ }
620
+ function autoSystemPromptSection(actions) {
621
+ if (actions.length === 0)
622
+ return '';
623
+ const bullets = actions.map((r) => `- ${r.name} — ${r.description}`).join('\n');
624
+ return `\n\n## Custom actions\n${bullets}`;
625
+ }
626
+ /**
627
+ * Wrap `getPreset(baseId)` with custom actions. Returns a new
628
+ * {@link PresetDescriptor} that advertises and dispatches the custom actions
629
+ * while delegating everything else to the base preset. With
630
+ * `includeActions`, the base's built-in surface is narrowed to that allowlist.
631
+ */
632
+ export function extendPreset(baseId, options) {
633
+ const base = getPreset(baseId);
634
+ // Snapshot the caller's array — the preset surface must stay immutable even
635
+ // if discovery/hot-reload code mutates the original list after construction
636
+ // (else getTools/prompt would drift from the byName dispatch map).
637
+ const actions = options.actions ? [...options.actions] : [];
638
+ const standalone = options.standalone === true;
639
+ assertActionsValid(actions, options.id, standalone);
640
+ const byName = new Map(actions.map((r) => [r.name, r]));
641
+ const include = options.includeActions != null ? new Set(options.includeActions) : null;
642
+ if (include) {
643
+ for (const name of include) {
644
+ if (!BUILTIN_ACTION_NAMES.has(name)) {
645
+ throw new SuperDocCliError(`includeActions: unknown action "${name}".`, {
646
+ code: 'INVALID_ARGUMENT',
647
+ details: { presetId: options.id, unknownAction: name },
648
+ });
649
+ }
650
+ }
651
+ }
652
+ // The allowlist is implemented as a DERIVED exclusion forwarded to the
653
+ // base, so core narrows the enum, the grouped description, AND the
654
+ // advertised argument properties natively — no hand-rebuilt schemas here
655
+ // (a second implementation of that narrowing is exactly what drifts).
656
+ const derivedExcludes = include ? ACTION_NAMES_LIST.filter((name) => !include.has(name)) : [];
657
+ const splitExclusions = (list) => splitCustomExclusions(byName, list);
658
+ /** Built-in exclusions the base applies: allowlist-derived ∪ requested. */
659
+ const builtinExcludesFor = (requested) => {
660
+ const merged = [...new Set([...derivedExcludes, ...(requested ?? [])])];
661
+ return merged.length > 0 ? merged : undefined;
662
+ };
663
+ async function getTools(provider, toolOptions) {
664
+ const { customExcluded, builtinExcluded } = splitExclusions(toolOptions?.excludeActions);
665
+ const excludeActions = builtinExcludesFor(builtinExcluded);
666
+ const baseOptions = toolOptions || excludeActions ? { ...toolOptions, excludeActions } : undefined;
667
+ const result = await base.getTools(provider, baseOptions);
668
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
669
+ if (activeActions.length === 0)
670
+ return result;
671
+ // Only re-place the anthropic marker when the base actually applied one —
672
+ // marking tools for a base that reported 'disabled' would make the marker
673
+ // and the cacheStrategy metadata disagree.
674
+ const cacheRequested = toolOptions?.cache === true && result.cacheStrategy !== 'disabled';
675
+ if (standalone) {
676
+ const extra = activeActions.map((action) => standaloneTool(provider, action));
677
+ const tools = renormalizeAnthropicCacheMarker([...result.tools, ...extra], provider, cacheRequested);
678
+ return { ...result, tools };
679
+ }
680
+ // Merge keeps the same tool count/order, so the marker is unaffected — but
681
+ // re-normalize anyway to stay correct if a base ever reorders or drops the
682
+ // perform_action tool (every built-in excluded → synthesized custom-only).
683
+ const merged = renormalizeAnthropicCacheMarker(mergeOrSynthesizePerformAction(result.tools, activeActions, provider), provider, cacheRequested);
684
+ return { ...result, tools: merged };
685
+ }
686
+ async function getCatalog() {
687
+ const catalog = await base.getCatalog();
688
+ let rows = catalog.tools;
689
+ // Advertised == dispatchable, catalog included: when includeActions
690
+ // narrows the surface, the catalog's superdoc_perform_action row must narrow
691
+ // WITH it — enum, description, AND argument properties — or getToolCatalog()
692
+ // still advertises inputs for actions the preset refuses. Rebuild the row
693
+ // from the SAME builder getTools drives through the base (buildPerform-
694
+ // ActionDefinition), so there is one narrowing implementation, not a second
695
+ // that drifts. Canonical ACTION_NAMES_LIST order matches the getTools row.
696
+ if (include) {
697
+ const includedBuiltins = ACTION_NAMES_LIST.filter((name) => include.has(name));
698
+ if (includedBuiltins.length === 0) {
699
+ rows = rows.filter((row) => row.toolName !== 'superdoc_perform_action');
700
+ }
701
+ else {
702
+ const def = buildPerformActionDefinition(includedBuiltins);
703
+ rows = rows.map((row) => row.toolName === 'superdoc_perform_action'
704
+ ? {
705
+ ...row,
706
+ description: def.description,
707
+ inputSchema: def.inputSchema,
708
+ }
709
+ : row);
710
+ }
711
+ }
712
+ const extraRows = actions.map((action) => ({
713
+ toolName: action.name,
714
+ description: action.description,
715
+ inputSchema: action.inputSchema,
716
+ mutates: true,
717
+ operations: [],
718
+ }));
719
+ const tools = [...rows, ...extraRows];
720
+ return { ...catalog, toolCount: tools.length, tools };
721
+ }
722
+ async function getSystemPrompt(promptOptions) {
723
+ // includeActions narrows the ENUM; the prompt must narrow WITH it — a
724
+ // per-action manual for an uncallable action teaches the model to call it.
725
+ const { customExcluded, builtinExcluded } = splitExclusions(promptOptions?.excludeActions);
726
+ const excludeActions = builtinExcludesFor(builtinExcluded);
727
+ const basePrompt = await base.getSystemPrompt(excludeActions ? { ...promptOptions, excludeActions } : undefined);
728
+ const activeActions = actions.filter((action) => !customExcluded.has(action.name));
729
+ const extra = options.systemPromptExtra ?? autoSystemPromptSection(activeActions);
730
+ return basePrompt + extra;
731
+ }
732
+ async function getMcpPrompt() {
733
+ const basePrompt = await base.getMcpPrompt();
734
+ const extra = options.systemPromptExtra ?? autoSystemPromptSection(actions);
735
+ return basePrompt + extra;
736
+ }
737
+ async function dispatch(documentHandle, toolName, args, invokeOptions) {
738
+ // Defense-in-depth parity with core: a host that narrowed the advertised
739
+ // surface passes the same exclusions here, and an excluded CUSTOM action
740
+ // must be refused before it runs (the base can only refuse built-ins).
741
+ const exclusionOptions = invokeOptions;
742
+ const excluded = new Set(exclusionOptions?.excludeActions ?? []);
743
+ if (toolName === 'superdoc_perform_action' && isRecord(args) && typeof args.action === 'string') {
744
+ // Advertised surface == dispatchable surface: in standalone mode custom
745
+ // actions are advertised as their own tools, so the (unadvertised)
746
+ // perform_action route must not execute them — it falls through to the
747
+ // base, which rejects the unknown action name.
748
+ if (!standalone) {
749
+ const action = byName.get(args.action);
750
+ if (action) {
751
+ if (excluded.has(action.name))
752
+ throwExcludedAction(toolName, action.name);
753
+ return runCustomAction(base, action, documentHandle, args, invokeOptions);
754
+ }
755
+ }
756
+ // The allowlist is a dispatch boundary too: a built-in outside
757
+ // includeActions is not advertised and must not execute on a
758
+ // guessed/stale call (defense-in-depth parity with excludeActions).
759
+ if (include && BUILTIN_ACTION_NAMES.has(args.action) && !include.has(args.action)) {
760
+ throwExcludedAction(toolName, args.action, { excludedBy: 'includeActions' });
761
+ }
762
+ }
763
+ if (standalone && byName.has(toolName)) {
764
+ if (excluded.has(toolName))
765
+ throwExcludedAction(toolName, toolName);
766
+ const action = byName.get(toolName);
767
+ return runCustomAction(base, action, documentHandle, args, invokeOptions, /* fromPerformAction */ false);
768
+ }
769
+ return base.dispatch(documentHandle, toolName, args, invokeOptions);
770
+ }
771
+ return {
772
+ id: options.id,
773
+ description: options.description ?? `${base.description} + ${actions.length} custom action(s).`,
774
+ supportsCacheControl: base.supportsCacheControl,
775
+ getTools,
776
+ getCatalog,
777
+ getSystemPrompt,
778
+ getMcpPrompt,
779
+ dispatch,
780
+ };
781
+ }