From 619042560d8f6c7fce88f310f71e53474edd5807 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 4 Mar 2026 22:45:20 +0100 Subject: [PATCH 1/2] feat: llm tools alpha version - Added 'skipAsATool' and 'essential' properties to CLI-only operation definitions to control tool catalog generation and mark essential tools. - Updated the 'buildSdkContract' function to include these new properties in the SDK contract output. - Adjusted operation categories to better reflect their usage, consolidating lifecycle operations under the session category. - Refactored parameter schemas and validation logic to accommodate new operation definitions. --- apps/cli/scripts/export-sdk-contract.ts | 3 + .../src/__tests__/conformance/scenarios.ts | 4 +- .../cli/src/__tests__/help-regression.test.ts | 2 +- .../src/cli/cli-only-operation-definitions.ts | 16 +- apps/cli/src/cli/commands.ts | 10 +- apps/cli/src/cli/operation-params.ts | 19 +- apps/cli/src/cli/operation-set.ts | 29 +- apps/cli/src/cli/types.ts | 12 +- apps/cli/src/lib/operation-args.ts | 7 + .../reference/_generated-manifest.json | 2 +- .../reference/mutations/apply.mdx | 1722 ++++++++++++++++- .../reference/mutations/preview.mdx | 1722 ++++++++++++++++- apps/docs/document-engine/sdks.mdx | 174 +- .../scripts/lib/contract-output-artifacts.ts | 2 + .../scripts/lib/contract-snapshot.ts | 5 + .../src/contract/operation-definitions.ts | 10 + packages/document-api/src/contract/schemas.ts | 267 ++- .../src/__tests__/contract-integrity.test.ts | 138 +- .../src/__tests__/cross-lang-parity.test.ts | 332 +--- packages/sdk/codegen/src/generate-node.mjs | 22 +- .../codegen/src/generate-tool-catalogs.mjs | 209 +- packages/sdk/langs/node/README.md | 18 +- packages/sdk/langs/node/src/index.ts | 4 +- .../node/src/runtime/transport-common.ts | 2 +- packages/sdk/langs/node/src/tools.ts | 321 ++- .../sdk/langs/python/superdoc/__init__.py | 4 +- .../python/superdoc/test_parity_helper.py | 5 - .../sdk/langs/python/superdoc/tools_api.py | 335 +--- .../__tests__/node-dual-package.test.mjs | 2 +- packages/sdk/scripts/sdk-validate.mjs | 91 +- 30 files changed, 4398 insertions(+), 1091 deletions(-) diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 62cb23faa1..556c759a95 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -282,6 +282,8 @@ function buildSdkContract() { entry.outputSchema = docOp.outputSchema; if (docOp.successSchema) entry.successSchema = docOp.successSchema; if (docOp.failureSchema) entry.failureSchema = docOp.failureSchema; + if (docOp.skipAsATool) entry.skipAsATool = true; + if (docOp.essential) entry.essential = true; } else { // CLI-only operation — metadata from canonical definitions const def = cliOnlyDef!; @@ -290,6 +292,7 @@ function buildSdkContract() { entry.supportsTrackedMode = def.sdkMetadata.supportsTrackedMode; entry.supportsDryRun = def.sdkMetadata.supportsDryRun; entry.outputSchema = def.outputSchema; + if (def.skipAsATool) entry.skipAsATool = true; } // Invariant: every operation must have outputSchema diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 6fecef8c12..714b80f06a 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -752,7 +752,7 @@ export const SUCCESS_SCENARIOS = { docPath, '--expected-revision', '0', - '--atomic-json', + '--atomic', 'true', '--change-mode', 'direct', @@ -785,7 +785,7 @@ export const SUCCESS_SCENARIOS = { 'mutations', 'apply', docPath, - '--atomic-json', + '--atomic', 'true', '--change-mode', 'direct', diff --git a/apps/cli/src/__tests__/help-regression.test.ts b/apps/cli/src/__tests__/help-regression.test.ts index 1b8b8e771b..cd5f8a6b80 100644 --- a/apps/cli/src/__tests__/help-regression.test.ts +++ b/apps/cli/src/__tests__/help-regression.test.ts @@ -8,7 +8,7 @@ describe('CLI help regression coverage', () => { ); expect(blocksDeleteCommand).toBeDefined(); - expect(CLI_HELP).toContain('blocks:'); + expect(CLI_HELP).toContain('core:'); expect(CLI_HELP).toContain(blocksDeleteCommand!.key); }); }); diff --git a/apps/cli/src/cli/cli-only-operation-definitions.ts b/apps/cli/src/cli/cli-only-operation-definitions.ts index 18f7347ff5..6e4f4227f4 100644 --- a/apps/cli/src/cli/cli-only-operation-definitions.ts +++ b/apps/cli/src/cli/cli-only-operation-definitions.ts @@ -31,6 +31,8 @@ export interface CliOnlyOperationDefinition { intentName: string; sdkMetadata: CliOnlySdkMetadata; outputSchema: Record; + /** When true, this operation is excluded from generated LLM tool catalogs. */ + skipAsATool?: boolean; } // --------------------------------------------------------------------------- @@ -39,7 +41,7 @@ export interface CliOnlyOperationDefinition { export const CLI_ONLY_OPERATION_DEFINITIONS: Record = { open: { - category: 'lifecycle', + category: 'session', description: 'Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text).', requiresDocumentContext: false, @@ -78,7 +80,7 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record; -const AGENT_HIDDEN_PARAM_NAMES = new Set(['out', 'expectedRevision', 'changeMode', 'dryRun']); +const AGENT_HIDDEN_PARAM_NAMES = new Set(['out', 'expectedRevision', 'dryRun']); function resolveRef(schema: JsonSchema, $defs?: Record): JsonSchema { if (schema.$ref && $defs) { @@ -288,19 +287,11 @@ const PARAM_FLAG_OVERRIDES: Partial>> = { - 'doc.mutations.preview': { - steps: { type: 'json' }, - }, - 'doc.mutations.apply': { - steps: { type: 'json' }, - }, -}; +const PARAM_SCHEMA_OVERRIDES: Partial>> = {}; // --------------------------------------------------------------------------- // Schema-derived param exclusions diff --git a/apps/cli/src/cli/operation-set.ts b/apps/cli/src/cli/operation-set.ts index 3bbd76ef75..b4633195e0 100644 --- a/apps/cli/src/cli/operation-set.ts +++ b/apps/cli/src/cli/operation-set.ts @@ -86,15 +86,30 @@ for (const group of REFERENCE_OPERATION_GROUPS) { } } +const REFERENCE_GROUP_TO_CATEGORY: Record = { + core: 'core', + mutations: 'core', + query: 'core', + blocks: 'core', + capabilities: 'core', + format: 'format', + 'format.paragraph': 'format', + styles: 'format', + 'styles.paragraph': 'format', + create: 'create', + tables: 'tables', + sections: 'sections', + lists: 'lists', + comments: 'comments', + trackChanges: 'trackChanges', + toc: 'toc', + history: 'history', +}; + function deriveCategoryFromDocApi(docApiId: OperationId): CliCategory { const group = REFERENCE_GROUP_BY_OP.get(docApiId); - if (!group) return 'query'; - - if (group === 'core' || group === 'mutations') { - return COMMAND_CATALOG[docApiId].mutates ? 'mutation' : 'query'; - } - - return group as CliCategory; + if (!group) return 'core'; + return REFERENCE_GROUP_TO_CATEGORY[group] ?? 'core'; } export function cliCategory(cliOpId: CliOperationId): CliCategory { diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts index eae4ba8a8f..e88981419c 100644 --- a/apps/cli/src/cli/types.ts +++ b/apps/cli/src/cli/types.ts @@ -115,19 +115,17 @@ export type CliOperationArgsById = { // --------------------------------------------------------------------------- export type CliCategory = - | 'query' - | 'mutation' + | 'core' | 'format' | 'create' - | 'blocks' + | 'tables' + | 'sections' | 'lists' | 'comments' | 'trackChanges' - | 'capabilities' + | 'toc' | 'history' - | 'lifecycle' - | 'session' - | 'introspection'; + | 'session'; /** The 10 CLI-only operation identifiers (without `doc.` prefix). Single source of truth. */ export const CLI_ONLY_OPERATIONS = [ diff --git a/apps/cli/src/lib/operation-args.ts b/apps/cli/src/lib/operation-args.ts index 64cc2765c6..eeb5d05e3b 100644 --- a/apps/cli/src/lib/operation-args.ts +++ b/apps/cli/src/lib/operation-args.ts @@ -104,6 +104,13 @@ export function validateValueAgainstTypeSpec(value: unknown, schema: CliTypeSpec if (schema.type === 'json') return; + if (schema.enum) { + if (!schema.enum.includes(value)) { + throw new CliError('VALIDATION_ERROR', `${path} must be one of: ${schema.enum.join(', ')}.`); + } + return; + } + if (schema.type === 'string') { if (typeof value !== 'string') throw new CliError('VALIDATION_ERROR', `${path} must be a string.`); return; diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 975bec65ea..4707d2498c 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -496,5 +496,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "e06db0688bf06e7963ac3e777833076f02a71b4fa840cf301ca76a51efedc48f" + "sourceHash": "42fa486394bfa91e4b5dd3bd935ae1b44cc14e4576239ecf1ccc1534417e6548" } diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 0cb238a07e..018bdf0e50 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -106,7 +106,7 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo | `atomic` | `true` | yes | Constant: `true` | | `changeMode` | enum | yes | `"direct"`, `"tracked"` | | `expectedRevision` | string | no | | -| `steps` | object[] | yes | | +| `steps` | object(op="text.rewrite") \\| object(op="text.insert") \\| object(op="text.delete") \\| object(op="format.apply") \\| object(op="assert")[] | yes | | ### Example request @@ -116,7 +116,40 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "changeMode": "direct", "expectedRevision": "rev-001", "steps": [ - {} + { + "args": { + "replacement": { + "text": "Hello, world." + }, + "style": { + "inline": { + "mode": "preserve", + "onNonUniform": "error", + "requireUniform": true + }, + "paragraph": { + "mode": "preserve" + } + } + }, + "id": "id-001", + "op": "text.rewrite", + "where": { + "by": "select", + "require": "first", + "select": { + "caseSensitive": true, + "mode": "contains", + "pattern": "hello world", + "type": "text" + }, + "within": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } + } + } ] } ``` @@ -185,7 +218,8 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "additionalProperties": false, "properties": { "atomic": { - "const": true + "const": true, + "type": "boolean" }, "changeMode": { "enum": [ @@ -198,7 +232,1687 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo }, "steps": { "items": { - "type": "object" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "replacement": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "blocks" + ], + "type": "object" + } + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear", + "merge" + ], + "type": "string" + }, + "onNonUniform": { + "enum": [ + "error", + "useLeadingRun", + "majority", + "union" + ] + }, + "requireUniform": { + "type": "boolean" + }, + "setMarks": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "italic": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "underline": { + "enum": [ + "on", + "off", + "clear" + ] + } + }, + "type": "object" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + } + }, + "required": [ + "replacement" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.rewrite", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "content": { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "position": { + "enum": [ + "before", + "after" + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "inherit", + "set", + "clear" + ], + "type": "string" + }, + "setMarks": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "italic": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "underline": { + "enum": [ + "on", + "off", + "clear" + ] + } + }, + "type": "object" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + } + }, + "required": [ + "position", + "content" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.insert", + "type": "string" + }, + "where": { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.delete", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "bold": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "border": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "space": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "sz": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "caps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "charScale": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "contextualAlternates": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "cs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "dstrike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "eastAsianLayout": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "combine": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "combineBrackets": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "vert": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "vertCompress": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "em": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emboss": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "fitText": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "highlight": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "imprint": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "italic": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "kerning": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "lang": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bidi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "ligatures": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "numForm": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "numSpacing": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "oMath": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outline": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "position": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "rFonts": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "ascii": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "asciiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cs": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "csTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsiaTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hAnsi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hAnsiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hint": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "rStyle": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rtl": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "shading": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fill": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "smallCaps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "snapToGrid": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "specVanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "strike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "stylisticSets": { + "oneOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "val": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "underline": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "themeColor": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + ] + }, + "vanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "vertAlign": { + "oneOf": [ + { + "enum": [ + "superscript", + "subscript", + "baseline" + ] + }, + { + "type": "null" + } + ] + }, + "webHidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "format.apply", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "expectCount": { + "type": "number" + } + }, + "required": [ + "expectCount" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "assert", + "type": "string" + }, + "where": { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select" + ], + "type": "object" + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + } + ] }, "type": "array" } diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index 502a946071..c6830ba16f 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -106,7 +106,7 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo | `atomic` | `true` | yes | Constant: `true` | | `changeMode` | enum | yes | `"direct"`, `"tracked"` | | `expectedRevision` | string | no | | -| `steps` | object[] | yes | | +| `steps` | object(op="text.rewrite") \\| object(op="text.insert") \\| object(op="text.delete") \\| object(op="format.apply") \\| object(op="assert")[] | yes | | ### Example request @@ -116,7 +116,40 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "changeMode": "direct", "expectedRevision": "rev-001", "steps": [ - {} + { + "args": { + "replacement": { + "text": "Hello, world." + }, + "style": { + "inline": { + "mode": "preserve", + "onNonUniform": "error", + "requireUniform": true + }, + "paragraph": { + "mode": "preserve" + } + } + }, + "id": "id-001", + "op": "text.rewrite", + "where": { + "by": "select", + "require": "first", + "select": { + "caseSensitive": true, + "mode": "contains", + "pattern": "hello world", + "type": "text" + }, + "within": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } + } + } ] } ``` @@ -175,7 +208,8 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo "additionalProperties": false, "properties": { "atomic": { - "const": true + "const": true, + "type": "boolean" }, "changeMode": { "enum": [ @@ -188,7 +222,1687 @@ The runtime capability snapshot also exposes this allowlist at `planEngine.suppo }, "steps": { "items": { - "type": "object" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "replacement": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "blocks" + ], + "type": "object" + } + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear", + "merge" + ], + "type": "string" + }, + "onNonUniform": { + "enum": [ + "error", + "useLeadingRun", + "majority", + "union" + ] + }, + "requireUniform": { + "type": "boolean" + }, + "setMarks": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "italic": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "underline": { + "enum": [ + "on", + "off", + "clear" + ] + } + }, + "type": "object" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + "paragraph": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + } + }, + "required": [ + "replacement" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.rewrite", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "content": { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "position": { + "enum": [ + "before", + "after" + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "inherit", + "set", + "clear" + ], + "type": "string" + }, + "setMarks": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "italic": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + }, + "underline": { + "enum": [ + "on", + "off", + "clear" + ] + } + }, + "type": "object" + } + }, + "required": [ + "mode" + ], + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + } + }, + "required": [ + "position", + "content" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.insert", + "type": "string" + }, + "where": { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "text.delete", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "inline": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "bold": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "border": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "space": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "sz": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "caps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "charScale": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "contextualAlternates": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "cs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "dstrike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "eastAsianLayout": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "combine": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "combineBrackets": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "vert": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "vertCompress": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "em": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emboss": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "fitText": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "id": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "highlight": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iCs": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "imprint": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "italic": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "kerning": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "lang": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bidi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "ligatures": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "numForm": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "numSpacing": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "oMath": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outline": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "position": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "rFonts": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "ascii": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "asciiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cs": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "csTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsia": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "eastAsiaTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hAnsi": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hAnsiTheme": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hint": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "rStyle": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rtl": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "shading": { + "oneOf": [ + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fill": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "val": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "smallCaps": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "snapToGrid": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "specVanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "strike": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "stylisticSets": { + "oneOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "val": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "underline": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "themeColor": { + "oneOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + ] + }, + "vanish": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "vertAlign": { + "oneOf": [ + { + "enum": [ + "superscript", + "subscript", + "baseline" + ] + }, + { + "type": "null" + } + ] + }, + "webHidden": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "required": [ + "inline" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "format.apply", + "type": "string" + }, + "where": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select", + "require" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "by": { + "const": "ref", + "type": "string" + }, + "ref": { + "type": "string" + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "ref" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "additionalProperties": false, + "properties": { + "expectCount": { + "type": "number" + } + }, + "required": [ + "expectCount" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "op": { + "const": "assert", + "type": "string" + }, + "where": { + "additionalProperties": false, + "properties": { + "by": { + "const": "select", + "type": "string" + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "$ref": "#/$defs/NodeAddress" + } + }, + "required": [ + "by", + "select" + ], + "type": "object" + } + }, + "required": [ + "id", + "op", + "where", + "args" + ], + "type": "object" + } + ] }, "type": "array" } diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 55c94d4d43..18a39fcda8 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -350,37 +350,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p -#### Lifecycle - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | - -#### Query - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.getNode` | `get-node` | Retrieve a single node by target position. | -| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.getText` | `get-text` | Extract the plain-text content of the document. | -| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | -| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | -| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | -| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | - -#### Mutation - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | -| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | -| `doc.delete` | `delete` | Delete content at a target position. | -| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | - #### Format | Operation | CLI command | Description | @@ -429,11 +398,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.numSpacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylisticSets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextualAlternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | - -#### Format / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | +| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | | `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | | `doc.format.paragraph.clearAlignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | @@ -452,19 +419,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.setShading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clearShading` | `format paragraph clear-shading` | Remove all paragraph shading. | -#### Styles - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | - -#### Styles / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | - #### Create | Operation | CLI command | Description | @@ -498,12 +452,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.sections.setPageBorders` | `sections set-page-borders` | Set page border configuration for a section. | | `doc.sections.clearPageBorders` | `sections clear-page-borders` | Clear page border configuration for a section. | -#### Blocks - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | - #### Lists | Operation | CLI command | Description | @@ -606,12 +554,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.trackChanges.get` | `track-changes get` | Retrieve a single tracked change by ID. | | `doc.trackChanges.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | -#### Capabilities - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | - #### History | Operation | CLI command | Description | @@ -624,52 +566,39 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.setDefault` | `session set-default` | Set the default session for subsequent commands. | -#### Introspection - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describeCommand` | `describe command` | Show detailed metadata for a single CLI operation. | - - - - -#### Lifecycle - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | -| `doc.save` | `save` | Save the current session to the original file or a new path. | -| `doc.close` | `close` | Close the active editing session and clean up resources. | - -#### Query +#### Core | Operation | CLI command | Description | | --- | --- | --- | | `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | -| `doc.get_node` | `get-node` | Retrieve a single node by target position. | -| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | -| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | -| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | -| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | +| `doc.getNode` | `get-node` | Retrieve a single node by target position. | +| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.getText` | `get-text` | Extract the plain-text content of the document. | +| `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | | `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | -| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | -| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | - -#### Mutation - -| Operation | CLI command | Description | -| --- | --- | --- | | `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | | `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | +| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | + + + #### Format @@ -719,11 +648,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.num_spacing` | `format num-spacing` | Set or clear the `numSpacing` inline run property on the target text range. | | `doc.format.stylistic_sets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextual_alternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | - -#### Format / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | +| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | +| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | | `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | | `doc.format.paragraph.clear_alignment` | `format paragraph clear-alignment` | Remove direct paragraph alignment, reverting to style-defined or default alignment. | @@ -742,19 +669,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.paragraph.set_shading` | `format paragraph set-shading` | Set paragraph shading (background fill, pattern color, pattern type). | | `doc.format.paragraph.clear_shading` | `format paragraph clear-shading` | Remove all paragraph shading. | -#### Styles - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. | - -#### Styles / Paragraph - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | -| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | - #### Create | Operation | CLI command | Description | @@ -788,12 +702,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.sections.set_page_borders` | `sections set-page-borders` | Set page border configuration for a section. | | `doc.sections.clear_page_borders` | `sections clear-page-borders` | Clear page border configuration for a section. | -#### Blocks - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | - #### Lists | Operation | CLI command | Description | @@ -896,12 +804,6 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.track_changes.get` | `track-changes get` | Retrieve a single tracked change by ID. | | `doc.track_changes.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | -#### Capabilities - -| Operation | CLI command | Description | -| --- | --- | --- | -| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | - #### History | Operation | CLI command | Description | @@ -914,18 +816,36 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.open` | `open` | Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text). | +| `doc.save` | `save` | Save the current session to the original file or a new path. | +| `doc.close` | `close` | Close the active editing session and clean up resources. | +| `doc.status` | `status` | Show the current session status and document metadata. | +| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | +| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | | `doc.session.list` | `session list` | List all active editing sessions. | | `doc.session.save` | `session save` | Persist the current session state. | | `doc.session.close` | `session close` | Close a specific editing session by ID. | | `doc.session.set_default` | `session set-default` | Set the default session for subsequent commands. | -#### Introspection +#### Core | Operation | CLI command | Description | | --- | --- | --- | -| `doc.status` | `status` | Show the current session status and document metadata. | -| `doc.describe` | `describe` | List all available CLI operations and contract metadata. | -| `doc.describe_command` | `describe command` | Show detailed metadata for a single CLI operation. | +| `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | +| `doc.get_node` | `get-node` | Retrieve a single node by target position. | +| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.get_text` | `get-text` | Extract the plain-text content of the document. | +| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | +| `doc.get_html` | `get-html` | Extract the document content as an HTML string. | +| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | +| `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | +| `doc.delete` | `delete` | Delete content at a target position. | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | +| `doc.capabilities.get` | `capabilities` | Query runtime capabilities supported by the current document engine. | diff --git a/packages/document-api/scripts/lib/contract-output-artifacts.ts b/packages/document-api/scripts/lib/contract-output-artifacts.ts index 71a26bd4f8..bfb6cc3b47 100644 --- a/packages/document-api/scripts/lib/contract-output-artifacts.ts +++ b/packages/document-api/scripts/lib/contract-output-artifacts.ts @@ -21,6 +21,8 @@ function buildOperationContractMap() { outputSchema: operation.schemas.output, successSchema: operation.schemas.success, failureSchema: operation.schemas.failure, + ...(operation.skipAsATool ? { skipAsATool: true } : {}), + ...(operation.essential ? { essential: true } : {}), }, ]), ); diff --git a/packages/document-api/scripts/lib/contract-snapshot.ts b/packages/document-api/scripts/lib/contract-snapshot.ts index 0c6828b496..9a267548e7 100644 --- a/packages/document-api/scripts/lib/contract-snapshot.ts +++ b/packages/document-api/scripts/lib/contract-snapshot.ts @@ -9,6 +9,7 @@ import { buildInternalContractSchemas, type OperationId, } from '../../src/index.js'; +import { OPERATION_DEFINITIONS } from '../../src/contract/operation-definitions.js'; import { sha256 } from './generation-utils.js'; export interface ContractOperationSnapshot { @@ -18,6 +19,8 @@ export interface ContractOperationSnapshot { schemas: ReturnType['operations'][keyof ReturnType< typeof buildInternalContractSchemas >['operations']]; + skipAsATool?: boolean; + essential?: boolean; } export interface ContractSnapshot { @@ -39,6 +42,8 @@ export function buildContractSnapshot(): ContractSnapshot { memberPath: OPERATION_MEMBER_PATH_MAP[operationId], metadata: COMMAND_CATALOG[operationId], schemas: internalSchemas.operations[operationId], + ...(OPERATION_DEFINITIONS[operationId]?.skipAsATool ? { skipAsATool: true } : {}), + ...(OPERATION_DEFINITIONS[operationId]?.essential ? { essential: true } : {}), })); const sourcePayload = { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index f786cf38fa..2656622463 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -63,6 +63,9 @@ export interface OperationDefinitionEntry { metadata: CommandStaticMetadata; referenceDocPath: string; referenceGroup: ReferenceGroupKey; + skipAsATool?: boolean; + /** When true, this tool is included in the default "essential" tool set. */ + essential?: boolean; } // --------------------------------------------------------------------------- @@ -199,6 +202,7 @@ const FORMAT_INLINE_ALIAS_OPERATION_DEFINITIONS: Record = { return discoveryResultSchema({ oneOf: [textMatchItemSchema, nodeMatchItemSchema] }, queryMatchMetaSchema); })(), }, - 'mutations.preview': { - input: objectSchema( + // --------------------------------------------------------------------------- + // Mutation step schema — discriminated union by `op` + // --------------------------------------------------------------------------- + + ...(() => { + // Targeting: SelectWhere | RefWhere + const selectWhereSchema = objectSchema( { - expectedRevision: { type: 'string' }, - atomic: { const: true }, - changeMode: { enum: ['direct', 'tracked'] }, - steps: arraySchema({ type: 'object' }), + by: { const: 'select', type: 'string' }, + select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, + within: nodeAddressSchema, + require: { enum: ['first', 'exactlyOne', 'all'] }, }, - ['atomic', 'changeMode', 'steps'], - ), - output: objectSchema( + ['by', 'select', 'require'], + ); + + const refWhereSchema = objectSchema( { - evaluatedRevision: { type: 'string' }, - steps: arraySchema({ type: 'object' }), - valid: { type: 'boolean' }, - failures: arraySchema({ type: 'object' }), + by: { const: 'ref', type: 'string' }, + ref: { type: 'string' }, + within: nodeAddressSchema, }, - ['evaluatedRevision', 'steps', 'valid'], - ), - }, - 'mutations.apply': { - input: objectSchema( + ['by', 'ref'], + ); + + const stepWhereSchema: JsonSchema = { oneOf: [selectWhereSchema, refWhereSchema] }; + + // Insert-only where (no 'all' require, no ref) + const insertWhereSchema = objectSchema( + { + by: { const: 'select', type: 'string' }, + select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, + within: nodeAddressSchema, + require: { enum: ['first', 'exactlyOne'] }, + }, + ['by', 'select', 'require'], + ); + + // Assert where (select only, no require) + const assertWhereSchema = objectSchema( + { + by: { const: 'select', type: 'string' }, + select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, + within: nodeAddressSchema, + }, + ['by', 'select'], + ); + + // Replacement payload + const replacementBlockSchema = objectSchema({ text: { type: 'string' } }, ['text']); + const replacementPayloadSchema: JsonSchema = { + oneOf: [ + objectSchema({ text: { type: 'string' } }, ['text']), + objectSchema({ blocks: arraySchema(replacementBlockSchema) }, ['blocks']), + ], + }; + + // Style policies + const inlineDirectiveSchema: JsonSchema = { enum: [...INLINE_DIRECTIVES] }; + const setMarksSchema = objectSchema({ + bold: inlineDirectiveSchema, + italic: inlineDirectiveSchema, + underline: inlineDirectiveSchema, + strike: inlineDirectiveSchema, + }); + const inlineStylePolicySchema = objectSchema( + { + mode: { enum: ['preserve', 'set', 'clear', 'merge'], type: 'string' }, + requireUniform: { type: 'boolean' }, + onNonUniform: { enum: ['error', 'useLeadingRun', 'majority', 'union'] }, + setMarks: setMarksSchema, + }, + ['mode'], + ); + const paragraphStylePolicySchema = objectSchema( + { + mode: { enum: ['preserve', 'set', 'clear'], type: 'string' }, + }, + ['mode'], + ); + const stylePolicySchema = objectSchema( + { + inline: inlineStylePolicySchema, + paragraph: paragraphStylePolicySchema, + }, + ['inline'], + ); + const insertStylePolicySchema = objectSchema( + { + inline: objectSchema( + { + mode: { enum: ['inherit', 'set', 'clear'], type: 'string' }, + setMarks: setMarksSchema, + }, + ['mode'], + ), + }, + ['inline'], + ); + + // Step variants + const textRewriteStepSchema = objectSchema( + { + id: { type: 'string' }, + op: { const: 'text.rewrite', type: 'string' }, + where: stepWhereSchema, + args: objectSchema( + { + replacement: replacementPayloadSchema, + style: stylePolicySchema, + }, + ['replacement'], + ), + }, + ['id', 'op', 'where', 'args'], + ); + + const textInsertStepSchema = objectSchema( + { + id: { type: 'string' }, + op: { const: 'text.insert', type: 'string' }, + where: insertWhereSchema, + args: objectSchema( + { + position: { enum: ['before', 'after'] }, + content: objectSchema({ text: { type: 'string' } }, ['text']), + style: insertStylePolicySchema, + }, + ['position', 'content'], + ), + }, + ['id', 'op', 'where', 'args'], + ); + + const textDeleteStepSchema = objectSchema( + { + id: { type: 'string' }, + op: { const: 'text.delete', type: 'string' }, + where: stepWhereSchema, + args: objectSchema({}), + }, + ['id', 'op', 'where', 'args'], + ); + + const formatApplyStepSchema = objectSchema( + { + id: { type: 'string' }, + op: { const: 'format.apply', type: 'string' }, + where: stepWhereSchema, + args: objectSchema( + { + inline: buildInlineRunPatchSchema(), + }, + ['inline'], + ), + }, + ['id', 'op', 'where', 'args'], + ); + + const assertStepSchema = objectSchema( + { + id: { type: 'string' }, + op: { const: 'assert', type: 'string' }, + where: assertWhereSchema, + args: objectSchema( + { + expectCount: { type: 'number' }, + }, + ['expectCount'], + ), + }, + ['id', 'op', 'where', 'args'], + ); + + const mutationStepSchema: JsonSchema = { + oneOf: [ + textRewriteStepSchema, + textInsertStepSchema, + textDeleteStepSchema, + formatApplyStepSchema, + assertStepSchema, + ], + }; + + const mutationsInputSchema = objectSchema( { expectedRevision: { type: 'string' }, - atomic: { const: true }, + atomic: { const: true, type: 'boolean' }, changeMode: { enum: ['direct', 'tracked'] }, - steps: arraySchema({ type: 'object' }), + steps: arraySchema(mutationStepSchema), }, ['atomic', 'changeMode', 'steps'], - ), - output: objectSchema( - { - success: { const: true }, - revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), - steps: arraySchema({ type: 'object' }), - trackedChanges: arraySchema({ type: 'object' }), - timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + ); + + return { + 'mutations.preview': { + input: mutationsInputSchema, + output: objectSchema( + { + evaluatedRevision: { type: 'string' }, + steps: arraySchema({ type: 'object' }), + valid: { type: 'boolean' }, + failures: arraySchema({ type: 'object' }), + }, + ['evaluatedRevision', 'steps', 'valid'], + ), }, - ['success', 'revision', 'steps', 'timing'], - ), - success: objectSchema( - { - success: { const: true }, - revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), - steps: arraySchema({ type: 'object' }), - timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + 'mutations.apply': { + input: mutationsInputSchema, + output: objectSchema( + { + success: { const: true }, + revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), + steps: arraySchema({ type: 'object' }), + trackedChanges: arraySchema({ type: 'object' }), + timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + }, + ['success', 'revision', 'steps', 'timing'], + ), + success: objectSchema( + { + success: { const: true }, + revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), + steps: arraySchema({ type: 'object' }), + timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + }, + ['success', 'revision', 'steps', 'timing'], + ), + // `mutations.apply` throws pre-apply plan-engine errors rather than returning + // receipt-style non-applied failures, but SDK contract consumers still require + // an explicit failure schema descriptor for mutation operations. + failure: preApplyFailureResultSchemaFor('mutations.apply'), }, - ['success', 'revision', 'steps', 'timing'], - ), - // `mutations.apply` throws pre-apply plan-engine errors rather than returning - // receipt-style non-applied failures, but SDK contract consumers still require - // an explicit failure schema descriptor for mutation operations. - failure: preApplyFailureResultSchemaFor('mutations.apply'), - }, + }; + })(), 'capabilities.get': { input: strictEmptyObjectSchema, output: capabilitiesOutputSchema, diff --git a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts index d97474cd6e..4ac16040ae 100644 --- a/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts +++ b/packages/sdk/codegen/src/__tests__/contract-integrity.test.ts @@ -45,10 +45,14 @@ type Contract = { type Catalog = { contractVersion: string; toolCount: number; - profiles: { - intent: { tools: Array<{ operationId: string; toolName: string }> }; - operation: { tools: Array<{ operationId: string; toolName: string }> }; - }; + tools: Array<{ + operationId: string; + toolName: string; + category?: string; + essential?: boolean; + requiredCapabilities?: string[]; + inputSchema?: Record; + }>; }; describe('Contract integrity', () => { @@ -136,23 +140,28 @@ describe('Contract integrity', () => { }); describe('Tool catalog integrity', () => { - test('tool counts match contract operation count', async () => { + test('tool counts match non-skipped contract operation count', async () => { const contract = await loadJson(CONTRACT_PATH); const catalog = await loadJson(CATALOG_PATH); - const opCount = Object.keys(contract.operations).length; + const nonSkippedOps = Object.values(contract.operations).filter( + (op) => !(op as Record).skipAsATool, + ); - expect(catalog.profiles.intent.tools.length).toBe(opCount); - expect(catalog.profiles.operation.tools.length).toBe(opCount); - expect(catalog.toolCount).toBe(opCount * 2); + expect(catalog.tools.length).toBe(nonSkippedOps.length); + expect(catalog.toolCount).toBe(nonSkippedOps.length); }); - test('tool name map covers all operations', async () => { + test('tool name map covers all non-skipped operations', async () => { const contract = await loadJson(CONTRACT_PATH); const nameMap = await loadJson>(NAME_MAP_PATH); - const contractOps = new Set(Object.keys(contract.operations)); + const nonSkippedOps = new Set( + Object.entries(contract.operations) + .filter(([, op]) => !(op as Record).skipAsATool) + .map(([id]) => id), + ); const mappedOps = new Set(Object.values(nameMap)); - for (const opId of contractOps) { + for (const opId of nonSkippedOps) { expect(mappedOps.has(opId)).toBe(true); } }); @@ -160,11 +169,9 @@ describe('Tool catalog integrity', () => { test('all catalog entries have required fields', async () => { const catalog = await loadJson(CATALOG_PATH); - for (const profile of ['intent', 'operation'] as const) { - for (const tool of catalog.profiles[profile].tools) { - expect(tool.operationId).toBeTruthy(); - expect(tool.toolName).toBeTruthy(); - } + for (const tool of catalog.tools) { + expect(tool.operationId).toBeTruthy(); + expect(tool.toolName).toBeTruthy(); } }); @@ -173,23 +180,26 @@ describe('Tool catalog integrity', () => { const opCount = Object.keys(contract.operations).length; const providers = ['openai', 'anthropic', 'vercel', 'generic']; + const nonSkippedCount = Object.values(contract.operations).filter( + (op) => !(op as Record).skipAsATool, + ).length; + for (const provider of providers) { - const bundle = await loadJson<{ profiles: Record }>( + const bundle = await loadJson<{ tools: unknown[] }>( path.join(REPO_ROOT, `packages/sdk/tools/tools.${provider}.json`), ); - expect(Array.isArray(bundle.profiles.intent)).toBe(true); - expect(Array.isArray(bundle.profiles.operation)).toBe(true); - expect(bundle.profiles.intent.length).toBe(opCount); - expect(bundle.profiles.operation.length).toBe(opCount); + expect(Array.isArray(bundle.tools)).toBe(true); + // nonSkippedCount tools + discover_tools + expect(bundle.tools.length).toBe(nonSkippedCount + 1); } }); test('OpenAI tools have required function shape', async () => { - const bundle = await loadJson<{ profiles: { intent: Array> } }>( + const bundle = await loadJson<{ tools: Array> }>( path.join(REPO_ROOT, 'packages/sdk/tools/tools.openai.json'), ); - for (const tool of bundle.profiles.intent) { + for (const tool of bundle.tools) { expect(tool.type).toBe('function'); const fn = tool.function as Record; expect(typeof fn.name).toBe('string'); @@ -199,11 +209,11 @@ describe('Tool catalog integrity', () => { }); test('Anthropic tools have required shape', async () => { - const bundle = await loadJson<{ profiles: { intent: Array> } }>( + const bundle = await loadJson<{ tools: Array> }>( path.join(REPO_ROOT, 'packages/sdk/tools/tools.anthropic.json'), ); - for (const tool of bundle.profiles.intent) { + for (const tool of bundle.tools) { expect(typeof tool.name).toBe('string'); expect(typeof tool.description).toBe('string'); expect(typeof tool.input_schema).toBe('object'); @@ -216,12 +226,15 @@ const POLICY_PATH = path.join(REPO_ROOT, 'packages/sdk/tools/tools-policy.json') type ToolsPolicy = { policyVersion: string; contractHash: string; - phases: Record; + groups: string[]; + groupDescriptions?: Record; + essentialTools?: string[]; + discoverTool?: { name: string; description: string; schema: Record }; defaults: { - maxToolsByProfile: Record; - minReadTools: number; + mode?: string; + maxTools: number; + alwaysInclude: string[]; foundationalOperationIds: string[]; - chooserDecisionVersion: string; }; capabilityFeatures: Record; }; @@ -231,30 +244,37 @@ describe('Tools policy integrity', () => { const policy = await loadJson(POLICY_PATH); expect(policy.policyVersion).toBeTruthy(); expect(policy.contractHash).toBeTruthy(); - expect(typeof policy.phases).toBe('object'); + expect(Array.isArray(policy.groups)).toBe(true); expect(typeof policy.defaults).toBe('object'); expect(typeof policy.capabilityFeatures).toBe('object'); }); - test('all 4 phase keys present with correct shape', async () => { + test('has essential tools list', async () => { const policy = await loadJson(POLICY_PATH); - for (const phase of ['read', 'locate', 'mutate', 'review']) { - expect(policy.phases[phase]).toBeDefined(); - expect(Array.isArray(policy.phases[phase].include)).toBe(true); - expect(Array.isArray(policy.phases[phase].exclude)).toBe(true); - expect(Array.isArray(policy.phases[phase].priority)).toBe(true); - } + expect(Array.isArray(policy.essentialTools)).toBe(true); + expect(policy.essentialTools!.length).toBeGreaterThan(0); }); - test('phase categories exist in catalog entries', async () => { + test('has discover_tools definition', async () => { + const policy = await loadJson(POLICY_PATH); + expect(policy.discoverTool).toBeDefined(); + expect(policy.discoverTool!.name).toBe('discover_tools'); + expect(typeof policy.discoverTool!.description).toBe('string'); + expect(typeof policy.discoverTool!.schema).toBe('object'); + }); + + test('default mode is essential', async () => { + const policy = await loadJson(POLICY_PATH); + expect(policy.defaults.mode).toBe('essential'); + }); + + test('group categories exist in catalog entries', async () => { const policy = await loadJson(POLICY_PATH); const catalog = await loadJson(CATALOG_PATH); - const catalogCategories = new Set(catalog.profiles.intent.tools.map((t) => (t as Record).category)); + const catalogCategories = new Set(catalog.tools.map((t) => t.category)); - for (const phaseRule of Object.values(policy.phases)) { - for (const category of [...phaseRule.include, ...phaseRule.exclude]) { - expect(catalogCategories.has(category)).toBe(true); - } + for (const group of policy.groups) { + expect(catalogCategories.has(group)).toBe(true); } }); @@ -277,14 +297,22 @@ describe('Tools policy integrity', () => { const catalog = await loadJson(CATALOG_PATH); for (const [category, expectedFeatures] of Object.entries(policy.capabilityFeatures)) { - const categoryTools = catalog.profiles.intent.tools.filter( - (t) => (t as Record).category === category, - ); + const categoryTools = catalog.tools.filter((t) => t.category === category); for (const tool of categoryTools) { - expect((tool as Record).requiredCapabilities).toEqual(expectedFeatures); + expect(tool.requiredCapabilities).toEqual(expectedFeatures); } } }); + + test('essential tools exist in catalog', async () => { + const policy = await loadJson(POLICY_PATH); + const catalog = await loadJson(CATALOG_PATH); + const catalogToolNames = new Set(catalog.tools.map((t) => t.toolName)); + + for (const toolName of policy.essentialTools ?? []) { + expect(catalogToolNames.has(toolName)).toBe(true); + } + }); }); describe('Intent name integrity', () => { @@ -295,13 +323,14 @@ describe('Intent name integrity', () => { } }); - test('contract intentNames match catalog intent profile toolNames', async () => { + test('contract intentNames match catalog toolNames', async () => { const contract = await loadJson(CONTRACT_PATH); const catalog = await loadJson(CATALOG_PATH); - const catalogIntentNames = new Map(catalog.profiles.intent.tools.map((t) => [t.operationId, t.toolName])); + const catalogIntentNames = new Map(catalog.tools.map((t) => [t.operationId, t.toolName])); for (const [id, op] of Object.entries(contract.operations)) { + if ((op as Record).skipAsATool) continue; const catalogName = catalogIntentNames.get(id); expect(catalogName).toBe(op.intentName); } @@ -319,7 +348,7 @@ describe('Intent name integrity', () => { }); describe('agentVisible param annotation integrity', () => { - const EXPECTED_HIDDEN = new Set(['out', 'expectedRevision', 'changeMode', 'dryRun']); + const EXPECTED_HIDDEN = new Set(['out', 'expectedRevision', 'dryRun']); test('expected transport-envelope params are agentVisible: false', async () => { const contract = await loadJson(CONTRACT_PATH); @@ -336,11 +365,10 @@ describe('agentVisible param annotation integrity', () => { const contract = await loadJson(CONTRACT_PATH); const catalog = await loadJson(CATALOG_PATH); - for (const tool of catalog.profiles.intent.tools) { - const entry = tool as Record; - const inputSchema = entry.inputSchema as { properties?: Record } | undefined; + for (const tool of catalog.tools) { + const inputSchema = tool.inputSchema as { properties?: Record } | undefined; if (!inputSchema?.properties) continue; - const op = contract.operations[entry.operationId as string]; + const op = contract.operations[tool.operationId]; if (!op) continue; const hiddenParams = op.params.filter((p) => p.agentVisible === false).map((p) => p.name); diff --git a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts index dfa5fee475..af091ef813 100644 --- a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts +++ b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts @@ -10,11 +10,10 @@ const PYTHON_SDK = path.join(REPO_ROOT, 'packages/sdk/langs/python'); // Helpers // -------------------------------------------------------------------------- -type SelectionEntry = { operationId: string; toolName: string; category: string; mutates: boolean; profile: string }; +type SelectionEntry = { operationId: string; toolName: string; category: string; mutates: boolean }; type ChooseResult = { selected: SelectionEntry[]; - excluded: Array<{ toolName: string; reason: string }>; - selectionMeta: Record; + meta: { provider: string; mode: string; groups: string[]; selectedCount: number }; }; /** Call the Python parity helper with a JSON command and parse the result. */ @@ -66,323 +65,155 @@ async function nodeTools() { } // -------------------------------------------------------------------------- -// Phase 1 — Minimal parity tests (3 test cases for 3 bug fixes) +// chooseTools parity — group-based selection // -------------------------------------------------------------------------- -describe('Cross-language parity (Phase 1)', () => { - test('chooseTools: foundational seeding includes both foundational ops', async () => { - const input = { - provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'read' as const }, - budget: { minReadTools: 2 }, - }; +describe('chooseTools parity — essential mode (default)', () => { + test('default mode returns only essential tools + discover_tools', async () => { + const input = { provider: 'generic' as const }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: { operationId: string }) => s.operationId); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); - expect(nodeIds).toContain('doc.info'); - expect(nodeIds).toContain('doc.find'); - expect(pyIds).toContain('doc.info'); - expect(pyIds).toContain('doc.find'); + // Both should return same essential tools + const nodeIds = nodeResult.selected.map((s) => s.operationId).sort(); + const pyIds = pyResult.selected.map((s) => s.operationId).sort(); expect(pyIds).toEqual(nodeIds); - }); - - test('constraint validation: mutuallyExclusive rejects in both runtimes', async () => { - const args = { type: 'paragraph', query: 'test' }; - - const { dispatchSuperDocTool } = await nodeTools(); - let nodeError: { code?: string } | null = null; - try { - await dispatchSuperDocTool({ doc: {} }, 'find_content', args); - } catch (error: unknown) { - nodeError = error as { code?: string }; - } + expect(nodeIds.length).toBeGreaterThan(0); - const pyResult = (await callPython({ - action: 'validateDispatchArgs', - operationId: 'doc.find', - args, - })) as { rejected?: boolean; code?: string }; + // Should be a small set (essential only) + expect(nodeIds.length).toBeLessThan(20); - expect(nodeError).not.toBeNull(); - expect(nodeError!.code).toBe('INVALID_ARGUMENT'); - expect(pyResult.rejected).toBe(true); - expect(pyResult.code).toBe('INVALID_ARGUMENT'); + // Meta should report essential mode + expect(nodeResult.meta.mode).toBe('essential'); + expect(pyResult.meta.mode).toBe('essential'); }); - test('type mismatches pass through to CLI: both runtimes accept true for a number param', async () => { - const args = { query: 'test', limit: true }; - - const pyResult = await callPython({ - action: 'validateDispatchArgs', - operationId: 'doc.find', - args, - }); - - expect(pyResult).toBe('passed'); - }); -}); - -// -------------------------------------------------------------------------- -// Phase 6 — Expanded golden tests -// -------------------------------------------------------------------------- - -describe('chooseTools parity — phases and profiles', () => { - const phases = ['read', 'locate', 'mutate', 'review'] as const; - const profiles = ['intent', 'operation'] as const; - - for (const phase of phases) { - for (const profile of profiles) { - test(`${phase}/${profile}: identical selected operationIds`, async () => { - const input = { provider: 'generic' as const, profile, taskContext: { phase } }; - - const { chooseTools } = await nodeTools(); - const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: SelectionEntry) => s.operationId); - - const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); - - expect(pyIds).toEqual(nodeIds); - }); - } - } -}); - -describe('chooseTools parity — budget constraints', () => { - test('maxTools=5, minReadTools=3: same selections', async () => { - const input = { - provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'read' as const }, - budget: { maxTools: 5, minReadTools: 3 }, - }; + test('essential + groups union: loads essential plus requested category', async () => { + const input = { provider: 'generic' as const, groups: ['comments' as const] }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: SelectionEntry) => s.operationId); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); + const nodeIds = nodeResult.selected.map((s) => s.operationId).sort(); + const pyIds = pyResult.selected.map((s) => s.operationId).sort(); expect(pyIds).toEqual(nodeIds); - expect(nodeIds.length).toBeLessThanOrEqual(5); + + // Should include comment tools + const nodeCategories = new Set(nodeResult.selected.map((s) => s.category)); + expect(nodeCategories.has('comments')).toBe(true); + + // Should also include essential tools (which are from core/history) + expect(nodeIds.length).toBeGreaterThan(5); }); - test('maxTools=1: only 1 tool selected', async () => { - const input = { - provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'mutate' as const }, - budget: { maxTools: 1, minReadTools: 0 }, - }; + test('includeDiscoverTool=false omits discover_tools', async () => { + const input = { provider: 'generic' as const, includeDiscoverTool: false }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - expect(nodeResult.selected.length).toBe(1); - expect(pyResult.selected.length).toBe(1); - expect(pyResult.selected[0].operationId).toBe(nodeResult.selected[0].operationId); + // discover_tools should NOT appear in the tools array + const toolNames = nodeResult.tools + .filter((t): t is Record => typeof t === 'object' && t !== null) + .map((t) => (t as Record).name as string); + expect(toolNames).not.toContain('discover_tools'); }); }); -describe('chooseTools parity — policy overrides', () => { - test('forceExclude removes tool from both runtimes', async () => { - const input = { - provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'read' as const }, - policy: { forceExclude: ['get_document_info'] }, - }; +describe('chooseTools parity — all mode (group-based selection)', () => { + test('mode=all with no groups: identical selected operationIds', async () => { + const input = { provider: 'generic' as const, mode: 'all' as const }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: SelectionEntry) => s.operationId); + const nodeIds = nodeResult.selected.map((s) => s.operationId).sort(); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); + const pyIds = pyResult.selected.map((s) => s.operationId).sort(); - expect(nodeIds).not.toContain('doc.info'); - expect(pyIds).not.toContain('doc.info'); expect(pyIds).toEqual(nodeIds); + expect(nodeIds.length).toBeGreaterThan(0); + expect(nodeResult.meta.mode).toBe('all'); }); - test('forceInclude adds tool in both runtimes', async () => { - const input = { - provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'read' as const }, - policy: { forceInclude: ['insert_content'] }, - }; + test('mode=all: core group always auto-included', async () => { + const input = { provider: 'generic' as const, mode: 'all' as const, groups: ['format' as const] }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: SelectionEntry) => s.operationId); + const nodeCategories = new Set(nodeResult.selected.map((s) => s.category)); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); + const pyCategories = new Set(pyResult.selected.map((s) => s.category)); - // insert_content is normally excluded in read phase (it's a mutation). - // forceInclude should still add it. - expect(nodeIds).toContain('doc.insert'); - expect(pyIds).toContain('doc.insert'); - expect(pyIds).toEqual(nodeIds); + // Core should be auto-included even though only 'format' was requested + expect(nodeCategories.has('core')).toBe(true); + expect(nodeCategories.has('format')).toBe(true); + expect(pyCategories.has('core')).toBe(true); + expect(pyCategories.has('format')).toBe(true); }); -}); -describe('chooseTools parity — capability filtering', () => { - test('hasComments=false excludes comment tools in both runtimes', async () => { + test('mode=all: specific groups only', async () => { const input = { provider: 'generic' as const, - profile: 'intent' as const, - taskContext: { phase: 'mutate' as const }, - documentFeatures: { - hasTables: false, - hasLists: false, - hasComments: false, - hasTrackedChanges: false, - isEmptyDocument: false, - }, + mode: 'all' as const, + groups: ['core' as const, 'comments' as const], }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); - const nodeIds = nodeResult.selected.map((s: SelectionEntry) => s.operationId); + const nodeCategories = new Set(nodeResult.selected.map((s) => s.category)); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - const pyIds = pyResult.selected.map((s) => s.operationId); + const pyCategories = new Set(pyResult.selected.map((s) => s.category)); - // No comment operations should be selected - const commentOps = nodeIds.filter((id: string) => id.startsWith('doc.comments.')); - expect(commentOps.length).toBe(0); - expect(pyIds).toEqual(nodeIds); + // Should only have core and comments + for (const cat of nodeCategories) { + expect(['core', 'comments']).toContain(cat); + } + expect(pyCategories).toEqual(nodeCategories); }); - test('selectionMeta matches between runtimes', async () => { - const input = { - provider: 'generic' as const, - profile: 'operation' as const, - taskContext: { phase: 'locate' as const }, - budget: { maxTools: 8 }, - }; + test('mode=all: meta matches between runtimes', async () => { + const input = { provider: 'generic' as const, mode: 'all' as const, groups: ['core' as const, 'tables' as const] }; const { chooseTools } = await nodeTools(); const nodeResult = await chooseTools(input); const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult; - expect(pyResult.selectionMeta).toEqual(nodeResult.selectionMeta); + expect(pyResult.meta.provider).toBe(nodeResult.meta.provider); + expect(pyResult.meta.mode).toBe('all'); + expect(pyResult.meta.selectedCount).toBe(nodeResult.meta.selectedCount); + expect(pyResult.meta.groups.sort()).toEqual(nodeResult.meta.groups.sort()); }); }); -describe('inferDocumentFeatures parity', () => { - test('standard doc.info response', async () => { - const infoResult = { - counts: { words: 500, paragraphs: 12, tables: 2, comments: 3, lists: 5, trackedChanges: 1 }, - }; - - const { inferDocumentFeatures } = await nodeTools(); - const nodeFeatures = inferDocumentFeatures(infoResult); - - const pyFeatures = await callPython({ action: 'inferDocumentFeatures', infoResult }); - - expect(pyFeatures).toEqual(nodeFeatures); - expect(nodeFeatures.hasTables).toBe(true); - expect(nodeFeatures.hasComments).toBe(true); - expect(nodeFeatures.hasLists).toBe(true); - expect(nodeFeatures.hasTrackedChanges).toBe(true); - expect(nodeFeatures.isEmptyDocument).toBe(false); - }); - - test('empty document', async () => { - const infoResult = { - counts: { words: 0, paragraphs: 1, tables: 0, comments: 0, lists: 0, trackedChanges: 0 }, - }; - - const { inferDocumentFeatures } = await nodeTools(); - const nodeFeatures = inferDocumentFeatures(infoResult); - - const pyFeatures = await callPython({ action: 'inferDocumentFeatures', infoResult }); - - expect(pyFeatures).toEqual(nodeFeatures); - expect(nodeFeatures.isEmptyDocument).toBe(true); - expect(nodeFeatures.hasTables).toBe(false); - }); - - test('missing counts keys', async () => { - const infoResult = { counts: {} }; - - const { inferDocumentFeatures } = await nodeTools(); - const nodeFeatures = inferDocumentFeatures(infoResult); - - const pyFeatures = await callPython({ action: 'inferDocumentFeatures', infoResult }); - - expect(pyFeatures).toEqual(nodeFeatures); - }); - - test('null info result', async () => { - const { inferDocumentFeatures } = await nodeTools(); - const nodeFeatures = inferDocumentFeatures(null); - - const pyFeatures = await callPython({ action: 'inferDocumentFeatures', infoResult: null }); - - expect(pyFeatures).toEqual(nodeFeatures); - }); -}); - -describe('Tool name resolution parity', () => { - test('all tool names in name map resolve identically', async () => { - const nameMap = JSON.parse( - readFileSync(path.join(REPO_ROOT, 'packages/sdk/tools/tool-name-map.json'), 'utf8'), - ) as Record; - - const { resolveToolOperation } = await nodeTools(); - - // Test a representative sample (first 10 entries) - const entries = Object.entries(nameMap).slice(0, 10); - for (const [toolName, expectedOpId] of entries) { - const nodeResult = await resolveToolOperation(toolName); - - const pyResult = await callPython({ action: 'resolveToolOperation', toolName }); - - expect(nodeResult).toBe(expectedOpId); - expect(pyResult).toBe(expectedOpId); - } - }); - - test('unknown tool name returns null in both runtimes', async () => { - const { resolveToolOperation } = await nodeTools(); - const nodeResult = await resolveToolOperation('nonexistent_tool_xyz'); - - const pyResult = await callPython({ action: 'resolveToolOperation', toolName: 'nonexistent_tool_xyz' }); - - expect(nodeResult).toBeNull(); - expect(pyResult).toBeNull(); - }); -}); +// -------------------------------------------------------------------------- +// Constraint validation parity +// -------------------------------------------------------------------------- describe('Constraint validation parity', () => { - test('requiresOneOf: missing required group rejects in both runtimes', async () => { - // doc.find has requiresOneOf: [["type", "query"]] — must provide at least one - const args = { limit: 10 }; + test('mutuallyExclusive rejects in both runtimes', async () => { + // doc.lists.list has mutuallyExclusive: [['query', 'within'], ...] + const args = { query: 'test', within: 'some-id' }; const { dispatchSuperDocTool } = await nodeTools(); let nodeError: { code?: string } | null = null; try { - await dispatchSuperDocTool({ doc: {} }, 'find_content', args); + await dispatchSuperDocTool({ doc: {} }, 'list_lists', args); } catch (error: unknown) { nodeError = error as { code?: string }; } const pyResult = (await callPython({ action: 'validateDispatchArgs', - operationId: 'doc.find', + operationId: 'doc.lists.list', args, })) as { rejected?: boolean; code?: string }; @@ -392,6 +223,19 @@ describe('Constraint validation parity', () => { expect(pyResult.code).toBe('INVALID_ARGUMENT'); }); + test('type mismatches pass through to CLI: both runtimes accept true for a number param', async () => { + // doc.lists.list has a 'limit' number param + const args = { limit: true }; + + const pyResult = await callPython({ + action: 'validateDispatchArgs', + operationId: 'doc.lists.list', + args, + }); + + expect(pyResult).toBe('passed'); + }); + test('unknown param rejected in both runtimes', async () => { const args = { unknownParam: 'value' }; @@ -416,16 +260,14 @@ describe('Constraint validation parity', () => { }); test('valid args pass in both runtimes', async () => { - // doc.find with just query (satisfies requiresOneOf) const args = { query: 'test' }; const pyResult = await callPython({ action: 'validateDispatchArgs', - operationId: 'doc.find', + operationId: 'doc.lists.list', args, }); - // When validation passes, helper returns 'passed' expect(pyResult).toBe('passed'); }); }); diff --git a/packages/sdk/codegen/src/generate-node.mjs b/packages/sdk/codegen/src/generate-node.mjs index e555af98d0..611e16239f 100644 --- a/packages/sdk/codegen/src/generate-node.mjs +++ b/packages/sdk/codegen/src/generate-node.mjs @@ -30,10 +30,26 @@ function generateContractTs(contract) { '/* eslint-disable */', '// Auto-generated by packages/sdk/codegen/src/generate-node.mjs', '', - `export const CONTRACT = ${JSON.stringify(contractForEmbed, null, 2)} as const;`, + '/** Internal literal — not exported so TS never serializes it for .d.ts. */', + `const _CONTRACT = ${JSON.stringify(contractForEmbed, null, 2)} as const;`, '', - 'export type Contract = typeof CONTRACT;', - `export type OperationEntry = Contract['operations'][keyof Contract['operations']];`, + '/** Re-exported with a simple type to avoid TS7056 (literal too large for .d.ts). */', + 'export const CONTRACT: {', + ' contractVersion: string;', + ' $defs?: Record;', + ' cli: Record;', + ' protocol: Record;', + ' operations: Record;', + '} = _CONTRACT as any;', + '', + "import type { OperationSpec } from '../runtime/transport-common.js';", + '', + 'export interface ContractOperation extends OperationSpec {', + ' outputSchema?: Record;', + ' successSchema?: Record;', + ' constraints?: Record | null;', + ' [key: string]: unknown;', + '}', '', ].join('\n'); } diff --git a/packages/sdk/codegen/src/generate-tool-catalogs.mjs b/packages/sdk/codegen/src/generate-tool-catalogs.mjs index a49eb59a0d..8765769c40 100644 --- a/packages/sdk/codegen/src/generate-tool-catalogs.mjs +++ b/packages/sdk/codegen/src/generate-tool-catalogs.mjs @@ -35,40 +35,39 @@ function toOperationToolName(operationId) { // Tools policy — shared data that both runtimes consume from tools-policy.json // --------------------------------------------------------------------------- +const GROUP_DESCRIPTIONS = { + core: 'Core operations: read nodes, get text, insert/replace/delete content, mutations', + format: 'Text formatting, paragraph styles, alignment, spacing, borders, shading', + create: 'Create structural elements: headings, paragraphs, tables, sections, TOC', + tables: 'Table creation, manipulation, formatting, borders, and cell operations', + sections: 'Page layout, margins, columns, headers/footers, page numbering', + lists: 'Bullet and numbered lists, indentation, list types', + comments: 'Comment threads — create, edit, delete, list', + trackChanges: 'Track changes — list, inspect, accept/reject', + toc: 'Table of contents — create, configure, update, manage entries', + history: 'Undo, redo, history inspection', + session: 'Session management — open, close, save, list sessions', +}; + const TOOLS_POLICY = { - policyVersion: 'v1', - phases: { - read: { - include: ['introspection', 'query'], - exclude: ['mutation', 'trackChanges', 'session', 'create', 'comments', 'format'], - priority: ['query', 'introspection'], - }, - locate: { - include: ['query'], - exclude: ['mutation', 'trackChanges', 'session', 'create', 'comments', 'format'], - priority: ['query'], - }, - mutate: { - include: ['query', 'mutation', 'format', 'comments', 'create'], - exclude: ['session'], - priority: ['query', 'mutation', 'create', 'format', 'comments'], - }, - review: { - include: ['query', 'trackChanges', 'comments'], - exclude: ['mutation', 'create', 'session', 'format'], - priority: ['trackChanges', 'comments', 'query'], - }, - }, + policyVersion: 'v3', + groups: [ + 'core', 'format', 'create', 'tables', 'sections', + 'lists', 'comments', 'trackChanges', 'toc', 'history', 'session', + ], + groupDescriptions: GROUP_DESCRIPTIONS, defaults: { - maxToolsByProfile: { intent: 12, operation: 16 }, - minReadTools: 2, - foundationalOperationIds: ['doc.info', 'doc.find'], - chooserDecisionVersion: 'v1', + mode: 'essential', + maxTools: 20, + alwaysInclude: ['core'], + foundationalOperationIds: ['doc.info', 'doc.query.match'], }, capabilityFeatures: { comments: ['hasComments'], trackChanges: ['hasTrackedChanges'], lists: ['hasLists'], + tables: ['hasTables'], + toc: ['hasToc'], }, }; @@ -78,6 +77,8 @@ const TOOLS_POLICY = { const CAPABILITY_FEATURES = TOOLS_POLICY.capabilityFeatures; + + function inferRequiredCapabilities(category) { return CAPABILITY_FEATURES[category] ?? []; } @@ -112,6 +113,67 @@ function inferSessionRequirements(operation) { }; } +// --------------------------------------------------------------------------- +// Schema sanitization — ensure JSON Schema 2020-12 compliance +// --------------------------------------------------------------------------- + +/** + * Recursively fix bare `{ const: value }` nodes to include `type`. + * Anthropic requires `const` to be accompanied by a `type` field. + */ +function sanitizeSchema(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema; + + const result = { ...schema }; + + // "type": "json" is a SuperDoc contract sentinel for "any JSON value". + // It's not valid in JSON Schema draft 2020-12 — replace with empty schema. + if (result.type === 'json') { + delete result.type; + return result; + } + + // Fix bare const: add type based on the const value + if ('const' in result && !result.type) { + const val = result.const; + if (typeof val === 'string') result.type = 'string'; + else if (typeof val === 'number') result.type = 'number'; + else if (typeof val === 'boolean') result.type = 'boolean'; + } + + // Recurse into nested structures + if (result.properties) { + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([k, v]) => [k, sanitizeSchema(v)]), + ); + } + if (Array.isArray(result.oneOf)) { + // Convert oneOf where every variant is { const: value } into { enum: [...] } + const allConst = result.oneOf.every((v) => v && typeof v === 'object' && 'const' in v && Object.keys(v).length <= 2); + if (allConst && result.oneOf.length > 0) { + const values = result.oneOf.map((v) => v.const); + delete result.oneOf; + result.enum = values; + } else { + result.oneOf = result.oneOf.map(sanitizeSchema); + } + } + if (Array.isArray(result.anyOf)) { + result.anyOf = result.anyOf.map(sanitizeSchema); + } + if (Array.isArray(result.allOf)) { + result.allOf = result.allOf.map(sanitizeSchema); + } + if (result.items) { + result.items = sanitizeSchema(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === 'object') { + result.additionalProperties = sanitizeSchema(result.additionalProperties); + } + + return result; +} + // --------------------------------------------------------------------------- // Build input schema from CLI params (for CLI-only ops or as fallback) // --------------------------------------------------------------------------- @@ -127,13 +189,15 @@ function buildInputSchemaFromParams(operation) { } let schema; - if (param.type === 'string') schema = { type: 'string' }; + if (param.type === 'string' && param.schema) schema = { type: 'string', ...param.schema }; + else if (param.type === 'string') schema = { type: 'string' }; else if (param.type === 'number') schema = { type: 'number' }; else if (param.type === 'boolean') schema = { type: 'boolean' }; else if (param.type === 'string[]') schema = { type: 'array', items: { type: 'string' } }; - else if (param.type === 'json' && param.schema) schema = param.schema; - else schema = {}; + else if (param.type === 'json' && param.schema && param.schema.type !== 'json') schema = param.schema; + else schema = { type: 'object' }; + schema = sanitizeSchema(schema); if (param.description) schema.description = param.description; properties[param.name] = schema; if (param.required) required.push(param.name); @@ -182,7 +246,7 @@ function buildCatalogEntry(operationId, operation, docApiTool, profile) { inputSchema, outputSchema, mutates: operation.mutates ?? false, - category: operation.category ?? 'misc', + category: operation.category ?? 'core', capabilities: inferCapabilities(operation), constraints: operation.constraints ?? undefined, errors: docApiTool?.possibleFailureCodes ?? [], @@ -258,28 +322,33 @@ export async function generateToolCatalogs(contract) { const docApiTools = await loadDocApiTools(); const intentTools = []; - const operationTools = []; for (const [operationId, operation] of Object.entries(contract.operations)) { + // Skip operations explicitly excluded from LLM tool catalogs + if (operation.skipAsATool) continue; + // Map to doc-api tool by stripping 'doc.' prefix const docApiName = operationId.replace(/^doc\./, ''); const docApiTool = docApiTools.get(docApiName); - intentTools.push(buildCatalogEntry(operationId, operation, docApiTool, 'intent')); - operationTools.push(buildCatalogEntry(operationId, operation, docApiTool, 'operation')); + const entry = buildCatalogEntry(operationId, operation, docApiTool, 'intent'); + if (operation.essential) entry.essential = true; + intentTools.push(entry); } + // Collect essential tool names + const essentialToolNames = intentTools + .filter((t) => t.essential) + .map((t) => t.toolName); + // Full catalog const catalog = { contractVersion: contract.contractVersion, generatedAt: null, namePolicyVersion: NAME_POLICY_VERSION, exposureVersion: EXPOSURE_VERSION, - toolCount: intentTools.length + operationTools.length, - profiles: { - intent: { name: 'intent', tools: intentTools }, - operation: { name: 'operation', tools: operationTools }, - }, + toolCount: intentTools.length, + tools: intentTools, }; // Tool name -> operation ID map @@ -287,11 +356,29 @@ export async function generateToolCatalogs(contract) { for (const tool of intentTools) { toolNameMap[tool.toolName] = tool.operationId; } - for (const tool of operationTools) { - toolNameMap[tool.toolName] = tool.operationId; - } - // Provider bundles + // Build discover_tools schema: lists available groups with descriptions + const discoverToolSchema = { + type: 'object', + properties: { + groups: { + type: 'array', + items: { + type: 'string', + enum: TOOLS_POLICY.groups, + }, + description: 'Which tool groups to load. You can request multiple at once.', + }, + }, + required: ['groups'], + }; + + const discoverToolDescription = + 'Load additional tool groups when you need capabilities beyond the essential set. ' + + 'Call this BEFORE attempting to use tools from a specific group.\n\nAvailable groups:\n' + + TOOLS_POLICY.groups.map((g) => ` - ${g}: ${GROUP_DESCRIPTIONS[g]}`).join('\n'); + + // Provider bundles (with discover_tools appended) const providers = { openai: { formatter: toOpenAiTool, file: 'tools.openai.json' }, anthropic: { formatter: toAnthropicTool, file: 'tools.anthropic.json' }, @@ -299,9 +386,33 @@ export async function generateToolCatalogs(contract) { generic: { formatter: toGenericTool, file: 'tools.generic.json' }, }; - // Tools policy with contract hash + // Build discover_tools in each provider format + const discoverToolByProvider = { + openai: { + type: 'function', + function: { name: 'discover_tools', description: discoverToolDescription, parameters: discoverToolSchema }, + }, + anthropic: { + name: 'discover_tools', description: discoverToolDescription, input_schema: discoverToolSchema, + }, + vercel: { + type: 'function', + function: { name: 'discover_tools', description: discoverToolDescription, parameters: discoverToolSchema }, + }, + generic: { + name: 'discover_tools', description: discoverToolDescription, parameters: discoverToolSchema, + }, + }; + + // Tools policy with contract hash and essential tool list const policy = { ...TOOLS_POLICY, + essentialTools: essentialToolNames, + discoverTool: { + name: 'discover_tools', + description: discoverToolDescription, + schema: discoverToolSchema, + }, contractHash: contract.sourceHash, }; @@ -317,13 +428,13 @@ export async function generateToolCatalogs(contract) { ), ]; - for (const [, { formatter, file }] of Object.entries(providers)) { + for (const [providerName, { formatter, file }] of Object.entries(providers)) { + const providerTools = intentTools.map(formatter); + // Append discover_tools as the last tool in the bundle + providerTools.push(discoverToolByProvider[providerName]); const bundle = { contractVersion: contract.contractVersion, - profiles: { - intent: intentTools.map(formatter), - operation: operationTools.map(formatter), - }, + tools: providerTools, }; writes.push(writeGeneratedFile(path.join(TOOLS_OUTPUT_DIR, file), JSON.stringify(bundle, null, 2) + '\n')); } diff --git a/packages/sdk/langs/node/README.md b/packages/sdk/langs/node/README.md index 15dbca14dd..2c97ebc4fe 100644 --- a/packages/sdk/langs/node/README.md +++ b/packages/sdk/langs/node/README.md @@ -94,14 +94,12 @@ client.doc.insert(params) The SDK includes built-in support for exposing document operations as AI tool definitions: ```ts -import { chooseTools, dispatchSuperDocTool, inferDocumentFeatures } from '@superdoc-dev/sdk'; +import { chooseTools, dispatchSuperDocTool } from '@superdoc-dev/sdk'; -// Get tool definitions for your AI provider +// Get tool definitions for your AI provider, filtered by group const { tools, selected } = await chooseTools({ - provider: 'openai', // 'openai' | 'anthropic' | 'vercel' | 'generic' - profile: 'intent', // human-friendly tool names - taskContext: { phase: 'mutate' }, - documentFeatures: inferDocumentFeatures(await client.doc.info()), + provider: 'openai', // 'openai' | 'anthropic' | 'vercel' | 'generic' + groups: ['core', 'format', 'comments'], // core is always auto-included }); // Dispatch a tool call from the AI model @@ -110,12 +108,12 @@ const result = await dispatchSuperDocTool(client, toolName, args); | Function | Description | |----------|-------------| -| `chooseTools(input)` | Select tools filtered by phase, capabilities, and budget | -| `listTools(provider, options?)` | List all tool definitions for a provider | +| `chooseTools(input)` | Select tools filtered by group for a provider | +| `listTools(provider)` | List all tool definitions for a provider | | `dispatchSuperDocTool(client, toolName, args)` | Execute a tool call against a client | | `resolveToolOperation(toolName)` | Map a tool name to its operation ID | -| `getToolCatalog(options?)` | Load the full tool catalog | -| `inferDocumentFeatures(infoResult)` | Derive feature flags from `doc.info` output | +| `getToolCatalog()` | Load the full tool catalog | +| `getAvailableGroups()` | List all available tool groups | ## Part of SuperDoc diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index c0901411de..ee8ed0ecd1 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -34,11 +34,11 @@ export { getSkill, installSkill, listSkills } from './skills.js'; export { chooseTools, dispatchSuperDocTool, + getAvailableGroups, getToolCatalog, - inferDocumentFeatures, listTools, resolveToolOperation, } from './tools.js'; export { SuperDocCliError } from './runtime/errors.js'; export type { InvokeOptions, OperationSpec, OperationParamSpec, SuperDocClientOptions } from './runtime/process.js'; -export type { DocumentFeatures, ToolChooserInput, ToolPhase, ToolProfile, ToolProvider } from './tools.js'; +export type { ToolChooserInput, ToolChooserMode, ToolGroup, ToolProvider } from './tools.js'; diff --git a/packages/sdk/langs/node/src/runtime/transport-common.ts b/packages/sdk/langs/node/src/runtime/transport-common.ts index aa6950c55e..696d02d44a 100644 --- a/packages/sdk/langs/node/src/runtime/transport-common.ts +++ b/packages/sdk/langs/node/src/runtime/transport-common.ts @@ -117,7 +117,7 @@ export function buildOperationArgv( } break; case 'jsonFlag': - argv.push(flag, JSON.stringify(value)); + argv.push(flag, typeof value === 'string' ? value : JSON.stringify(value)); break; } } diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index 2d72e2a9fd..3063433e79 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -2,41 +2,34 @@ import { readFile } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CONTRACT } from './generated/contract.js'; +import { CONTRACT, type ContractOperation } from './generated/contract.js'; import type { InvokeOptions } from './runtime/process.js'; import { SuperDocCliError } from './runtime/errors.js'; export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; -export type ToolProfile = 'intent' | 'operation'; -export type ToolPhase = 'read' | 'locate' | 'mutate' | 'review'; - -export type DocumentFeatures = { - hasTables: boolean; - hasLists: boolean; - hasComments: boolean; - hasTrackedChanges: boolean; - isEmptyDocument: boolean; -}; + +export type ToolGroup = + | 'core' + | 'format' + | 'create' + | 'tables' + | 'sections' + | 'lists' + | 'comments' + | 'trackChanges' + | 'toc' + | 'history' + | 'session'; + +export type ToolChooserMode = 'essential' | 'all'; export type ToolChooserInput = { provider: ToolProvider; - profile?: ToolProfile; - documentFeatures?: Partial; - taskContext?: { - phase?: ToolPhase; - previousToolCalls?: Array<{ toolName: string; ok: boolean }>; - }; - budget?: { - maxTools?: number; - minReadTools?: number; - }; - policy?: { - includeCategories?: string[]; - excludeCategories?: string[]; - allowMutatingTools?: boolean; - forceInclude?: string[]; - forceExclude?: string[]; - }; + groups?: ToolGroup[]; + /** Default: 'essential'. When 'essential', only essential tools are returned (plus any from `groups`). */ + mode?: ToolChooserMode; + /** Whether to include the discover_tools meta-tool. Default: true when mode='essential', false when mode='all'. */ + includeDiscoverTool?: boolean; }; export type ToolCatalog = { @@ -45,29 +38,27 @@ export type ToolCatalog = { namePolicyVersion: string; exposureVersion: string; toolCount: number; - profiles: { - intent: { name: 'intent'; tools: ToolCatalogEntry[] }; - operation: { name: 'operation'; tools: ToolCatalogEntry[] }; - }; + tools: ToolCatalogEntry[]; }; type ToolCatalogEntry = { operationId: string; toolName: string; - profile: ToolProfile; - source: 'operation' | 'intent'; + profile: string; + source: string; description: string; inputSchema: Record; outputSchema: Record; mutates: boolean; category: string; + essential?: boolean; capabilities: string[]; constraints?: Record; errors: string[]; - examples: Array<{ description: string; args: Record }>; + examples: unknown[]; commandTokens: string[]; profileTags: string[]; - requiredCapabilities: Array; + requiredCapabilities: string[]; sessionRequirements: { requiresOpenContext: boolean; supportsSessionTargeting: boolean; @@ -88,12 +79,19 @@ const providerFileByName: Record = { type ToolsPolicy = { policyVersion: string; contractHash: string; - phases: Record; + groups: string[]; + groupDescriptions?: Record; + essentialTools?: string[]; + discoverTool?: { + name: string; + description: string; + schema: Record; + }; defaults: { - maxToolsByProfile: Record; - minReadTools: number; + mode?: string; + maxTools: number; + alwaysInclude: string[]; foundationalOperationIds: string[]; - chooserDecisionVersion: string; }; capabilityFeatures: Record; }; @@ -160,7 +158,7 @@ async function readJson(fileName: string): Promise { async function loadProviderBundle(provider: ToolProvider): Promise<{ contractVersion: string; - profiles: Record; + tools: unknown[]; }> { return readJson(providerFileByName[provider]); } @@ -173,28 +171,12 @@ async function loadCatalog(): Promise { return readJson('catalog.json'); } -function normalizeFeatures(features?: Partial): DocumentFeatures { - return { - hasTables: Boolean(features?.hasTables), - hasLists: Boolean(features?.hasLists), - hasComments: Boolean(features?.hasComments), - hasTrackedChanges: Boolean(features?.hasTrackedChanges), - isEmptyDocument: Boolean(features?.isEmptyDocument), - }; -} - -function stableSortByPhasePriority(entries: ToolCatalogEntry[], priorityOrder: string[]): ToolCatalogEntry[] { - const priority = new Map(priorityOrder.map((category, index) => [category, index])); - return [...entries].sort((a, b) => { - const aPriority = priority.get(a.category) ?? Number.MAX_SAFE_INTEGER; - const bPriority = priority.get(b.category) ?? Number.MAX_SAFE_INTEGER; - if (aPriority !== bPriority) return aPriority - bPriority; - return a.toolName.localeCompare(b.toolName); - }); +/** All available tool groups from the policy. */ +export function getAvailableGroups(): ToolGroup[] { + const policy = loadPolicy(); + return policy.groups as ToolGroup[]; } -type ContractOperation = (typeof CONTRACT.operations)[keyof typeof CONTRACT.operations]; - const OPERATION_INDEX: Record = Object.fromEntries( Object.entries(CONTRACT.operations).map(([id, op]) => [id, op]), ); @@ -299,27 +281,17 @@ function resolveDocApiMethod( return cursor as (args: unknown, options?: InvokeOptions) => Promise; } -export async function getToolCatalog(options: { profile?: ToolProfile } = {}): Promise { - const catalog = await loadCatalog(); - if (!options.profile) return catalog; - - return { - ...catalog, - profiles: { - intent: options.profile === 'intent' ? catalog.profiles.intent : { name: 'intent', tools: [] }, - operation: options.profile === 'operation' ? catalog.profiles.operation : { name: 'operation', tools: [] }, - }, - }; +export async function getToolCatalog(): Promise { + return loadCatalog(); } -export async function listTools(provider: ToolProvider, options: { profile?: ToolProfile } = {}): Promise { - const profile = options.profile ?? 'intent'; +export async function listTools(provider: ToolProvider): Promise { const bundle = await loadProviderBundle(provider); - const tools = bundle.profiles[profile]; + const tools = bundle.tools; if (!Array.isArray(tools)) { - throw new SuperDocCliError('Tool provider bundle is missing profile tools.', { + throw new SuperDocCliError('Tool provider bundle is missing tools array.', { code: 'TOOLS_ASSET_INVALID', - details: { provider, profile }, + details: { provider }, }); } return tools; @@ -330,40 +302,27 @@ export async function resolveToolOperation(toolName: string): Promise | null | undefined): DocumentFeatures { - if (!isRecord(infoResult)) { - return { - hasTables: false, - hasLists: false, - hasComments: false, - hasTrackedChanges: false, - isEmptyDocument: false, - }; - } - - const counts = isRecord(infoResult.counts) ? infoResult.counts : {}; - const words = typeof counts.words === 'number' ? counts.words : 0; - const paragraphs = typeof counts.paragraphs === 'number' ? counts.paragraphs : 0; - const tables = typeof counts.tables === 'number' ? counts.tables : 0; - const comments = typeof counts.comments === 'number' ? counts.comments : 0; - const lists = - typeof counts.lists === 'number' ? counts.lists : typeof counts.listItems === 'number' ? counts.listItems : 0; - const trackedChanges = - typeof counts.trackedChanges === 'number' - ? counts.trackedChanges - : typeof counts.tracked_changes === 'number' - ? counts.tracked_changes - : 0; - - return { - hasTables: tables > 0, - hasLists: lists > 0, - hasComments: comments > 0, - hasTrackedChanges: trackedChanges > 0, - isEmptyDocument: words === 0 && paragraphs <= 1, - }; -} - +/** + * Select tools for a specific provider. + * + * **mode='essential'** (default): Returns only essential tools + discover_tools. + * Pass `groups` to additionally load all tools from those categories. + * + * **mode='all'**: Returns all tools from requested groups (or all groups if + * `groups` is omitted). No discover_tools included by default. + * + * @example + * ```ts + * // Default: 5 essential tools + discover_tools + * const { tools } = await chooseTools({ provider: 'openai' }); + * + * // Essential + all comment tools + * const { tools } = await chooseTools({ provider: 'openai', groups: ['comments'] }); + * + * // All tools (old behavior) + * const { tools } = await chooseTools({ provider: 'openai', mode: 'all' }); + * ``` + */ export async function chooseTools(input: ToolChooserInput): Promise<{ tools: unknown[]; selected: Array<{ @@ -371,112 +330,47 @@ export async function chooseTools(input: ToolChooserInput): Promise<{ toolName: string; category: string; mutates: boolean; - profile: ToolProfile; }>; - excluded: Array<{ toolName: string; reason: string }>; - selectionMeta: { - profile: ToolProfile; - phase: ToolPhase; - maxTools: number; - minReadTools: number; - selectedCount: number; - decisionVersion: string; + meta: { provider: ToolProvider; + mode: string; + groups: string[]; + selectedCount: number; }; }> { const catalog = await loadCatalog(); const policy = loadPolicy(); - const profile = input.profile ?? 'intent'; - const phase = input.taskContext?.phase ?? 'read'; - const phasePolicy = policy.phases[phase]; - const featureMap = normalizeFeatures(input.documentFeatures); - - const maxTools = Math.max(1, input.budget?.maxTools ?? policy.defaults.maxToolsByProfile[profile]); - const minReadTools = Math.max(0, input.budget?.minReadTools ?? policy.defaults.minReadTools); - const includeCategories = new Set(input.policy?.includeCategories ?? phasePolicy.include); - const excludeCategories = new Set([...(input.policy?.excludeCategories ?? []), ...phasePolicy.exclude]); - const allowMutatingTools = input.policy?.allowMutatingTools ?? phase === 'mutate'; + const mode = input.mode ?? (policy.defaults.mode as ToolChooserMode) ?? 'essential'; + const includeDiscover = input.includeDiscoverTool ?? mode === 'essential'; - const excluded: Array<{ toolName: string; reason: string }> = []; - const profileTools = catalog.profiles[profile].tools; - const indexByToolName = new Map(profileTools.map((tool) => [tool.toolName, tool])); - - let candidates = profileTools.filter((tool) => { - if (tool.requiredCapabilities.some((capability) => !featureMap[capability])) { - excluded.push({ toolName: tool.toolName, reason: 'missing-required-capability' }); - return false; - } - - if (!allowMutatingTools && tool.mutates) { - excluded.push({ toolName: tool.toolName, reason: 'mutations-disabled' }); - return false; - } + let selected: ToolCatalogEntry[]; - if (includeCategories.size > 0 && !includeCategories.has(tool.category)) { - excluded.push({ toolName: tool.toolName, reason: 'category-not-included' }); - return false; - } + if (mode === 'essential') { + // Essential tools + any explicitly requested groups + const essentialNames = new Set(policy.essentialTools ?? []); + const requestedGroups = input.groups ? new Set(input.groups) : null; - if (excludeCategories.has(tool.category)) { - excluded.push({ toolName: tool.toolName, reason: 'phase-category-excluded' }); + selected = catalog.tools.filter((tool) => { + if (essentialNames.has(tool.toolName)) return true; + if (requestedGroups && requestedGroups.has(tool.category)) return true; return false; + }); + } else { + // mode='all': original behavior — filter by groups + const alwaysInclude = new Set(policy.defaults.alwaysInclude ?? ['core']); + let groups: Set; + if (input.groups) { + groups = new Set([...input.groups, ...alwaysInclude]); + } else { + groups = new Set(policy.groups); } - - return true; - }); - - const forceExclude = new Set(input.policy?.forceExclude ?? []); - candidates = candidates.filter((tool) => { - if (!forceExclude.has(tool.toolName)) return true; - excluded.push({ toolName: tool.toolName, reason: 'force-excluded' }); - return false; - }); - - // Resolve forceInclude tools — these are guaranteed slots exempt from budget trimming. - const forcedToolNames = new Set(input.policy?.forceInclude ?? []); - const forcedTools: ToolCatalogEntry[] = []; - for (const forcedToolName of forcedToolNames) { - const forced = indexByToolName.get(forcedToolName); - if (!forced) { - excluded.push({ toolName: forcedToolName, reason: 'not-in-profile' }); - continue; - } - candidates.push(forced); - forcedTools.push(forced); - } - - candidates = [...new Map(candidates.map((tool) => [tool.toolName, tool])).values()]; - - // Start with forceInclude tools — they always occupy a slot. - const selected: ToolCatalogEntry[] = [...forcedTools]; - const selectedNames = new Set(selected.map((tool) => tool.toolName)); - - const foundationalIds = new Set(policy.defaults.foundationalOperationIds); - const foundational = candidates.filter( - (tool) => foundationalIds.has(tool.operationId) && !selectedNames.has(tool.toolName), - ); - for (const tool of foundational) { - if (selected.length >= minReadTools || selected.length >= maxTools) break; - selected.push(tool); - selectedNames.add(tool.toolName); - } - - const remaining = stableSortByPhasePriority( - candidates.filter((tool) => !selectedNames.has(tool.toolName)), - phasePolicy.priority, - ); - - for (const tool of remaining) { - if (selected.length >= maxTools) { - excluded.push({ toolName: tool.toolName, reason: 'budget-trim' }); - continue; - } - selected.push(tool); + selected = catalog.tools.filter((tool) => groups.has(tool.category)); } + // Build provider-formatted tools from the provider bundle const bundle = await loadProviderBundle(input.provider); - const providerTools = Array.isArray(bundle.profiles[profile]) ? bundle.profiles[profile] : []; + const providerTools = Array.isArray(bundle.tools) ? bundle.tools : []; const providerIndex = new Map( providerTools .filter((tool): tool is Record => isRecord(tool)) @@ -488,6 +382,16 @@ export async function chooseTools(input: ToolChooserInput): Promise<{ .map((tool) => providerIndex.get(tool.toolName)) .filter((tool): tool is Record => Boolean(tool)); + // Append discover_tools if requested + if (includeDiscover) { + const discoverTool = providerIndex.get('discover_tools'); + if (discoverTool) { + selectedProviderTools.push(discoverTool); + } + } + + const resolvedGroups = mode === 'essential' ? (input.groups ?? []) : (input.groups ?? policy.groups); + return { tools: selectedProviderTools, selected: selected.map((tool) => ({ @@ -495,17 +399,12 @@ export async function chooseTools(input: ToolChooserInput): Promise<{ toolName: tool.toolName, category: tool.category, mutates: tool.mutates, - profile: tool.profile, })), - excluded, - selectionMeta: { - profile, - phase, - maxTools, - minReadTools, - selectedCount: selected.length, - decisionVersion: policy.defaults.chooserDecisionVersion, + meta: { provider: input.provider, + mode, + groups: [...resolvedGroups], + selectedCount: selectedProviderTools.length, }, }; } diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index 5462891c64..218a1131bb 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -5,8 +5,8 @@ choose_tools, dispatch_superdoc_tool, dispatch_superdoc_tool_async, + get_available_groups, get_tool_catalog, - infer_document_features, list_tools, resolve_tool_operation, ) @@ -21,7 +21,7 @@ "get_tool_catalog", "list_tools", "resolve_tool_operation", - "infer_document_features", + "get_available_groups", "choose_tools", "dispatch_superdoc_tool", "dispatch_superdoc_tool_async", diff --git a/packages/sdk/langs/python/superdoc/test_parity_helper.py b/packages/sdk/langs/python/superdoc/test_parity_helper.py index 40f77d291e..0794ee726f 100644 --- a/packages/sdk/langs/python/superdoc/test_parity_helper.py +++ b/packages/sdk/langs/python/superdoc/test_parity_helper.py @@ -40,11 +40,6 @@ def main() -> None: result = resolve_tool_operation(command['toolName']) print(json.dumps({'ok': True, 'result': result})) - elif action == 'inferDocumentFeatures': - from superdoc.tools_api import infer_document_features - result = infer_document_features(command['infoResult']) - print(json.dumps({'ok': True, 'result': result})) - elif action == 'assertCollabAccepted': # Verify collab params pass through to the runtime without # SDK-level rejection. We build the argv from the operation spec diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index ff19ed7a59..b10fe5139c 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -10,43 +10,18 @@ from .generated.contract import OPERATION_INDEX ToolProvider = Literal['openai', 'anthropic', 'vercel', 'generic'] -ToolProfile = Literal['intent', 'operation'] -ToolPhase = Literal['read', 'locate', 'mutate', 'review'] - - -class DocumentFeatures(TypedDict): - hasTables: bool - hasLists: bool - hasComments: bool - hasTrackedChanges: bool - isEmptyDocument: bool - - -class ToolChooserPolicy(TypedDict, total=False): - includeCategories: List[str] - excludeCategories: List[str] - allowMutatingTools: bool - forceInclude: List[str] - forceExclude: List[str] - - -class ToolChooserBudget(TypedDict, total=False): - maxTools: int - minReadTools: int - - -class ToolChooserTaskContext(TypedDict, total=False): - phase: ToolPhase - previousToolCalls: List[Dict[str, Any]] +ToolGroup = Literal[ + 'core', 'format', 'create', 'tables', 'sections', + 'lists', 'comments', 'trackChanges', 'toc', 'history', 'session', +] +ToolChooserMode = Literal['essential', 'all'] class ToolChooserInput(TypedDict, total=False): provider: ToolProvider - profile: ToolProfile - documentFeatures: DocumentFeatures - taskContext: ToolChooserTaskContext - budget: ToolChooserBudget - policy: ToolChooserPolicy + groups: List[ToolGroup] + mode: ToolChooserMode + includeDiscoverTool: bool # Policy is loaded from the generated tools-policy.json artifact. @@ -104,46 +79,19 @@ def _read_json_asset(name: str) -> Dict[str, Any]: return cast(Dict[str, Any], parsed) -def get_tool_catalog(options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - profile = (options or {}).get('profile') - catalog = _read_json_asset('catalog.json') - if profile not in ('intent', 'operation', None): - raise SuperDocError( - 'profile must be "intent" or "operation".', - code='INVALID_ARGUMENT', - details={'profile': profile}, - ) - - if profile is None: - return catalog - - filtered = dict(catalog) - profiles = catalog.get('profiles') if isinstance(catalog.get('profiles'), dict) else {} - filtered['profiles'] = { - 'intent': profiles.get('intent') if profile == 'intent' else {'name': 'intent', 'tools': []}, - 'operation': profiles.get('operation') if profile == 'operation' else {'name': 'operation', 'tools': []}, - } - return filtered - +def get_tool_catalog() -> Dict[str, Any]: + return _read_json_asset('catalog.json') -def list_tools(provider: ToolProvider, options: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: - profile = (options or {}).get('profile', 'intent') - if profile not in ('intent', 'operation'): - raise SuperDocError( - 'profile must be "intent" or "operation".', - code='INVALID_ARGUMENT', - details={'profile': profile}, - ) +def list_tools(provider: ToolProvider) -> List[Dict[str, Any]]: bundle = _read_json_asset(PROVIDER_FILE[provider]) - profiles = bundle.get('profiles') - if not isinstance(profiles, dict): - raise SuperDocError('Tool provider artifact is missing profiles.', code='TOOLS_ASSET_INVALID', details={'provider': provider}) - - tools = profiles.get(profile) + tools = bundle.get('tools') if not isinstance(tools, list): - raise SuperDocError('Tool provider artifact profile is invalid.', code='TOOLS_ASSET_INVALID', details={'provider': provider, 'profile': profile}) - + raise SuperDocError( + 'Tool provider bundle is missing tools array.', + code='TOOLS_ASSET_INVALID', + details={'provider': provider}, + ) return cast(List[Dict[str, Any]], tools) @@ -153,55 +101,9 @@ def resolve_tool_operation(tool_name: str) -> Optional[str]: return value if isinstance(value, str) else None -def infer_document_features(info_result: Optional[Mapping[str, Any]]) -> DocumentFeatures: - if not isinstance(info_result, dict): - return { - 'hasTables': False, - 'hasLists': False, - 'hasComments': False, - 'hasTrackedChanges': False, - 'isEmptyDocument': False, - } - - counts: Mapping[str, Any] = {} - if isinstance(info_result.get('counts'), dict): - counts = cast(Mapping[str, Any], info_result['counts']) - - words = counts.get('words') if isinstance(counts.get('words'), (int, float)) else 0 - paragraphs = counts.get('paragraphs') if isinstance(counts.get('paragraphs'), (int, float)) else 0 - tables = counts.get('tables') if isinstance(counts.get('tables'), (int, float)) else 0 - comments = counts.get('comments') if isinstance(counts.get('comments'), (int, float)) else 0 - lists = counts.get('lists') if isinstance(counts.get('lists'), (int, float)) else counts.get('listItems', 0) - tracked = counts.get('trackedChanges') if isinstance(counts.get('trackedChanges'), (int, float)) else counts.get('tracked_changes', 0) - - return { - 'hasTables': bool(tables and tables > 0), - 'hasLists': bool(lists and lists > 0), - 'hasComments': bool(comments and comments > 0), - 'hasTrackedChanges': bool(tracked and tracked > 0), - 'isEmptyDocument': bool(words == 0 and paragraphs <= 1), - } - - -def _normalize_features(features: Optional[Mapping[str, Any]]) -> DocumentFeatures: - return { - 'hasTables': bool(features.get('hasTables')) if features else False, - 'hasLists': bool(features.get('hasLists')) if features else False, - 'hasComments': bool(features.get('hasComments')) if features else False, - 'hasTrackedChanges': bool(features.get('hasTrackedChanges')) if features else False, - 'isEmptyDocument': bool(features.get('isEmptyDocument')) if features else False, - } - - -def _priority_sort(tools: List[Dict[str, Any]], priority: List[str]) -> List[Dict[str, Any]]: - priority_index = {category: index for index, category in enumerate(priority)} - return sorted( - tools, - key=lambda tool: ( - priority_index.get(str(tool.get('category')), 10_000), - str(tool.get('toolName', '')), - ), - ) +def get_available_groups() -> List[str]: + policy = _load_policy() + return list(policy.get('groups', [])) def _extract_provider_tool_name(tool: Dict[str, Any]) -> Optional[str]: @@ -222,135 +124,95 @@ def _extract_provider_tool_name(tool: Dict[str, Any]) -> Optional[str]: def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: - provider = input.get('provider') - if provider not in ('openai', 'anthropic', 'vercel', 'generic'): - raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + """Select tools for a specific provider. - profile = cast(ToolProfile, input.get('profile', 'intent')) - if profile not in ('intent', 'operation'): - raise SuperDocError('profile must be "intent" or "operation".', code='INVALID_ARGUMENT', details={'profile': profile}) + **mode='essential'** (default): Returns only essential tools + discover_tools. + Pass ``groups`` to additionally load all tools from those categories. - task_context = input.get('taskContext', {}) - phase = cast(ToolPhase, task_context.get('phase', 'read')) - if phase not in ('read', 'locate', 'mutate', 'review'): - raise SuperDocError('phase must be read|locate|mutate|review.', code='INVALID_ARGUMENT', details={'phase': phase}) + **mode='all'**: Returns all tools from requested groups (or all groups if + ``groups`` is omitted). No discover_tools included by default. - catalog = _read_json_asset('catalog.json') - tools_policy = _load_policy() - profile_tools = ( - catalog.get('profiles', {}).get(profile, {}).get('tools') - if isinstance(catalog.get('profiles'), dict) - else [] - ) - if not isinstance(profile_tools, list): - raise SuperDocError('Catalog profile tools are invalid.', code='TOOLS_ASSET_INVALID', details={'profile': profile}) - - policy = input.get('policy', {}) - budget = input.get('budget', {}) - - defaults = tools_policy.get('defaults', {}) - max_by_profile = defaults.get('maxToolsByProfile', {}) - max_tools = int(budget.get('maxTools', max_by_profile.get(profile, 12))) - min_read_tools = int(budget.get('minReadTools', defaults.get('minReadTools', 2))) - max_tools = max(1, max_tools) - min_read_tools = max(0, min_read_tools) - - phase_policy = tools_policy.get('phases', {}).get(phase, {'include': [], 'exclude': [], 'priority': []}) - include_categories = set(policy.get('includeCategories') or phase_policy['include']) - exclude_categories = set((policy.get('excludeCategories') or []) + phase_policy['exclude']) - allow_mutating_tools = bool(policy.get('allowMutatingTools', phase == 'mutate')) - - features = _normalize_features(input.get('documentFeatures')) - excluded: List[Dict[str, str]] = [] - - def should_include(tool: Dict[str, Any]) -> bool: - required_caps = tool.get('requiredCapabilities') - if isinstance(required_caps, list): - for capability in required_caps: - if isinstance(capability, str) and capability in features and not features[capability]: - excluded.append({'toolName': str(tool.get('toolName')), 'reason': 'missing-required-capability'}) - return False - - if not allow_mutating_tools and bool(tool.get('mutates')): - excluded.append({'toolName': str(tool.get('toolName')), 'reason': 'mutations-disabled'}) - return False + Example:: - category = str(tool.get('category', '')) - if include_categories and category not in include_categories: - excluded.append({'toolName': str(tool.get('toolName')), 'reason': 'category-not-included'}) - return False + # Default: essential tools + discover_tools + result = choose_tools({'provider': 'openai'}) - if category in exclude_categories: - excluded.append({'toolName': str(tool.get('toolName')), 'reason': 'phase-category-excluded'}) - return False + # Essential + all comment tools + result = choose_tools({'provider': 'openai', 'groups': ['comments']}) - return True + # All tools (old behavior) + result = choose_tools({'provider': 'openai', 'mode': 'all'}) + """ + provider = input.get('provider') + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): + raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) - candidates = [tool for tool in profile_tools if isinstance(tool, dict) and should_include(cast(Dict[str, Any], tool))] + catalog = _read_json_asset('catalog.json') + tools_policy = _load_policy() - force_exclude = set(policy.get('forceExclude') or []) - filtered: List[Dict[str, Any]] = [] - for tool in candidates: - name = str(tool.get('toolName')) - if name in force_exclude: - excluded.append({'toolName': name, 'reason': 'force-excluded'}) - continue - filtered.append(tool) - - # Resolve forceInclude tools — these are guaranteed slots exempt from budget trimming. - index_by_name = {str(tool.get('toolName')): tool for tool in profile_tools if isinstance(tool, dict)} - forced_tool_names_raw: list = list(policy.get('forceInclude') or []) - forced_tool_names_seen: set = set() - forced_tools: List[Dict[str, Any]] = [] - for forced_name in forced_tool_names_raw: - forced_name_key = str(forced_name) - if forced_name_key in forced_tool_names_seen: - continue - forced_tool_names_seen.add(forced_name_key) - forced = index_by_name.get(forced_name_key) - if forced is None: - excluded.append({'toolName': str(forced_name), 'reason': 'not-in-profile'}) - continue - filtered.append(forced) - forced_tools.append(forced) - - deduped: Dict[str, Dict[str, Any]] = {} - for tool in filtered: - deduped[str(tool.get('toolName'))] = tool - candidates = list(deduped.values()) - - # Start with forceInclude tools — they always occupy a slot. - selected: List[Dict[str, Any]] = list(forced_tools) - selected_names: set = {str(tool.get('toolName')) for tool in selected} - - foundational_ids = set(defaults.get('foundationalOperationIds', [])) - foundational = [tool for tool in candidates if str(tool.get('operationId')) in foundational_ids and str(tool.get('toolName')) not in selected_names] - for tool in foundational: - if len(selected) >= min_read_tools or len(selected) >= max_tools: - break - selected.append(tool) - selected_names.add(str(tool.get('toolName'))) - - remaining = [tool for tool in _priority_sort(candidates, phase_policy['priority']) if str(tool.get('toolName')) not in selected_names] - - for tool in remaining: - if len(selected) >= max_tools: - excluded.append({'toolName': str(tool.get('toolName')), 'reason': 'budget-trim'}) - continue - selected.append(tool) + catalog_tools = catalog.get('tools') + if not isinstance(catalog_tools, list): + raise SuperDocError('Catalog tools are invalid.', code='TOOLS_ASSET_INVALID') + + default_mode = tools_policy.get('defaults', {}).get('mode', 'essential') + mode = input.get('mode', default_mode) + include_discover_raw = input.get('includeDiscoverTool') + include_discover = include_discover_raw if include_discover_raw is not None else (mode == 'essential') + + if mode == 'essential': + # Essential tools + any explicitly requested groups + essential_names = set(tools_policy.get('essentialTools', [])) + requested_groups = set(input.get('groups', [])) if input.get('groups') is not None else None + + selected = [ + tool for tool in catalog_tools + if isinstance(tool, dict) and ( + str(tool.get('toolName', '')) in essential_names + or (requested_groups is not None and str(tool.get('category', '')) in requested_groups) + ) + ] + else: + # mode='all': original behavior — filter by groups + always_include = set(tools_policy.get('defaults', {}).get('alwaysInclude', ['core'])) + requested_groups_list = input.get('groups') + if requested_groups_list is not None: + groups = set(list(requested_groups_list) + list(always_include)) + else: + groups = set(tools_policy.get('groups', [])) + + selected = [ + tool for tool in catalog_tools + if isinstance(tool, dict) and str(tool.get('category', '')) in groups + ] + # Build provider-formatted tools from the provider bundle provider_bundle = _read_json_asset(PROVIDER_FILE[provider]) - provider_profiles = provider_bundle.get('profiles') if isinstance(provider_bundle.get('profiles'), dict) else {} - provider_tools = provider_profiles.get(profile) if isinstance(provider_profiles, dict) else [] + provider_tools_raw = provider_bundle.get('tools') if isinstance(provider_bundle.get('tools'), list) else [] provider_index: Dict[str, Dict[str, Any]] = {} - for tool in provider_tools: + for tool in provider_tools_raw: if not isinstance(tool, dict): continue name = _extract_provider_tool_name(tool) if name is not None: provider_index[name] = tool - selected_provider_tools = [provider_index[name] for name in [str(tool.get('toolName')) for tool in selected] if name in provider_index] + selected_provider_tools = [ + provider_index[name] + for name in [str(tool.get('toolName')) for tool in selected] + if name in provider_index + ] + + # Append discover_tools if requested + if include_discover: + discover_tool = provider_index.get('discover_tools') + if discover_tool is not None: + selected_provider_tools.append(discover_tool) + + resolved_groups: List[str] = ( + list(input.get('groups', []) if input.get('groups') is not None else []) + if mode == 'essential' + else list(input.get('groups') if input.get('groups') is not None else tools_policy.get('groups', [])) + ) return { 'tools': selected_provider_tools, @@ -360,19 +222,14 @@ def should_include(tool: Dict[str, Any]) -> bool: 'toolName': str(tool.get('toolName')), 'category': str(tool.get('category')), 'mutates': bool(tool.get('mutates')), - 'profile': str(tool.get('profile')), } for tool in selected ], - 'excluded': excluded, - 'selectionMeta': { - 'profile': profile, - 'phase': phase, - 'maxTools': max_tools, - 'minReadTools': min_read_tools, - 'selectedCount': len(selected), - 'decisionVersion': defaults.get('chooserDecisionVersion', 'v1'), + 'meta': { 'provider': provider, + 'mode': mode, + 'groups': sorted(resolved_groups), + 'selectedCount': len(selected_provider_tools), }, } diff --git a/packages/sdk/scripts/__tests__/node-dual-package.test.mjs b/packages/sdk/scripts/__tests__/node-dual-package.test.mjs index b70ebe1b49..6873a23977 100644 --- a/packages/sdk/scripts/__tests__/node-dual-package.test.mjs +++ b/packages/sdk/scripts/__tests__/node-dual-package.test.mjs @@ -39,7 +39,7 @@ const EXPECTED_EXPORTS = [ 'chooseTools', 'dispatchSuperDocTool', 'getToolCatalog', - 'inferDocumentFeatures', + 'getAvailableGroups', 'resolveToolOperation', 'SuperDocCliError', ]; diff --git a/packages/sdk/scripts/sdk-validate.mjs b/packages/sdk/scripts/sdk-validate.mjs index 102190dcfd..8d4629601e 100644 --- a/packages/sdk/scripts/sdk-validate.mjs +++ b/packages/sdk/scripts/sdk-validate.mjs @@ -156,7 +156,7 @@ async function main() { await check('Python SDK imports successfully', async () => { await run('python3', [ '-c', - 'from superdoc import SuperDocClient, AsyncSuperDocClient, SuperDocError, get_tool_catalog, list_tools, resolve_tool_operation, choose_tools, dispatch_superdoc_tool, dispatch_superdoc_tool_async, infer_document_features', + 'from superdoc import SuperDocClient, AsyncSuperDocClient, SuperDocError, get_tool_catalog, list_tools, resolve_tool_operation, choose_tools, dispatch_superdoc_tool, dispatch_superdoc_tool_async, get_available_groups', ], { cwd: path.join(REPO_ROOT, 'packages/sdk/langs/python'), }); @@ -165,46 +165,41 @@ async function main() { // 7. Tool catalog integrity await check('Tool catalog operation count matches contract', async () => { const catalog = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json')); - const contractOpCount = Object.keys(contract.operations).length; - const intentToolCount = catalog.profiles.intent.tools.length; - const operationToolCount = catalog.profiles.operation.tools.length; + // Count non-skipped operations in the contract + const nonSkippedOps = Object.entries(contract.operations).filter(([, op]) => !op.skipAsATool); + const expectedCount = nonSkippedOps.length; + const toolCount = catalog.tools.length; - if (intentToolCount !== contractOpCount) { - throw new Error(`Intent tools (${intentToolCount}) != contract ops (${contractOpCount})`); - } - if (operationToolCount !== contractOpCount) { - throw new Error(`Operation tools (${operationToolCount}) != contract ops (${contractOpCount})`); + if (toolCount !== expectedCount) { + throw new Error(`Catalog tools (${toolCount}) != non-skipped contract ops (${expectedCount})`); } }); - // 8. Tool name map covers all operations + // 8. Tool name map covers all non-skipped operations await check('Tool name map covers all operations', async () => { const nameMap = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tool-name-map.json')); - const contractOps = new Set(Object.keys(contract.operations)); const mappedOps = new Set(Object.values(nameMap)); - for (const opId of contractOps) { + for (const [opId, op] of Object.entries(contract.operations)) { + if (op.skipAsATool) continue; if (!mappedOps.has(opId)) { throw new Error(`Operation ${opId} not covered by any tool name`); } } }); - // 9. Provider bundles exist and have correct profile counts + // 9. Provider bundles exist and have correct tool counts await check('Provider bundles are consistent', async () => { const providers = ['openai', 'anthropic', 'vercel', 'generic']; - const contractOpCount = Object.keys(contract.operations).length; + const catalog = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json')); + const expectedCount = catalog.tools.length; for (const provider of providers) { const bundle = await readJson(path.join(REPO_ROOT, `packages/sdk/tools/tools.${provider}.json`)); - if (!bundle.profiles) throw new Error(`${provider} bundle missing profiles`); - if (!Array.isArray(bundle.profiles.intent)) throw new Error(`${provider} bundle missing intent tools`); - if (!Array.isArray(bundle.profiles.operation)) throw new Error(`${provider} bundle missing operation tools`); - if (bundle.profiles.intent.length !== contractOpCount) { - throw new Error(`${provider} intent tool count mismatch`); - } - if (bundle.profiles.operation.length !== contractOpCount) { - throw new Error(`${provider} operation tool count mismatch`); + if (!Array.isArray(bundle.tools)) throw new Error(`${provider} bundle missing tools array`); + // Provider bundles include catalog tools + synthetic tools (e.g. discover_tools) + if (bundle.tools.length < expectedCount) { + throw new Error(`${provider} tool count (${bundle.tools.length}) < catalog (${expectedCount})`); } } }); @@ -237,30 +232,28 @@ async function main() { await check('Catalog input schemas present and required params match contract', async () => { const catalog = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json')); - for (const profileKey of ['intent', 'operation']) { - for (const tool of catalog.profiles[profileKey].tools) { - if (!tool.inputSchema || typeof tool.inputSchema !== 'object') { - throw new Error(`${tool.operationId} (${profileKey}) missing inputSchema`); - } + for (const tool of catalog.tools) { + if (!tool.inputSchema || typeof tool.inputSchema !== 'object') { + throw new Error(`${tool.operationId} missing inputSchema`); + } - // Verify required params from contract appear as required in inputSchema - const contractOp = contract.operations[tool.operationId]; - if (!contractOp) continue; - - const contractRequired = (contractOp.params ?? []) - .filter((p) => p.required === true) - .map((p) => p.name) - // Exclude transport-envelope params that are intentionally omitted from tool schemas - .filter((name) => !['out', 'json', 'expectedRevision', 'changeMode', 'dryRun'].includes(name)); - - const schemaRequired = new Set(tool.inputSchema.required ?? []); - for (const name of contractRequired) { - // Only check if the param is in the schema properties (some params are omitted by design) - if (tool.inputSchema.properties && name in tool.inputSchema.properties && !schemaRequired.has(name)) { - throw new Error( - `${tool.operationId} (${profileKey}): param "${name}" is required in contract but not in inputSchema`, - ); - } + // Verify required params from contract appear as required in inputSchema + const contractOp = contract.operations[tool.operationId]; + if (!contractOp) continue; + + const contractRequired = (contractOp.params ?? []) + .filter((p) => p.required === true) + .map((p) => p.name) + // Exclude transport-envelope params that are intentionally omitted from tool schemas + .filter((name) => !['out', 'json', 'expectedRevision', 'changeMode', 'dryRun'].includes(name)); + + const schemaRequired = new Set(tool.inputSchema.required ?? []); + for (const name of contractRequired) { + // Only check if the param is in the schema properties (some params are omitted by design) + if (tool.inputSchema.properties && name in tool.inputSchema.properties && !schemaRequired.has(name)) { + throw new Error( + `${tool.operationId}: param "${name}" is required in contract but not in inputSchema`, + ); } } } @@ -309,11 +302,15 @@ async function main() { const openaiBundle = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tools.openai.json')); const nameMap = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tool-name-map.json')); - for (const tool of openaiBundle.profiles.intent) { + // Synthetic meta-tools (e.g. discover_tools) are not in the name map + const syntheticTools = new Set(['discover_tools']); + + for (const tool of openaiBundle.tools) { const name = tool?.function?.name ?? tool?.name; if (typeof name !== 'string' || !name) { - throw new Error('OpenAI intent tool missing extractable name'); + throw new Error('OpenAI tool missing extractable name'); } + if (syntheticTools.has(name)) continue; if (!(name in nameMap)) { throw new Error(`OpenAI tool name "${name}" not in tool-name-map`); } From c1fec5fc3eb485d76d1132b0bbca003539cca497 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Mar 2026 00:16:38 +0100 Subject: [PATCH 2/2] docs: add AI Agents section with LLM Tools, MCP Server, and Skills documentation --- apps/docs/docs.json | 13 +- .../document-engine/ai-agents/llm-tools.mdx | 363 ++++++++++++++++++ .../{mcp.mdx => ai-agents/mcp-server.mdx} | 1 + .../docs/document-engine/ai-agents/skills.mdx | 18 + apps/docs/getting-started/ai-agents.mdx | 4 + 5 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 apps/docs/document-engine/ai-agents/llm-tools.mdx rename apps/docs/document-engine/{mcp.mdx => ai-agents/mcp-server.mdx} (98%) create mode 100644 apps/docs/document-engine/ai-agents/skills.mdx diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 28b9528b38..af1f72e509 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -111,7 +111,14 @@ }, "document-engine/sdks", "document-engine/cli", - "document-engine/mcp" + { + "group": "AI Agents", + "pages": [ + "document-engine/ai-agents/llm-tools", + "document-engine/ai-agents/skills", + "document-engine/ai-agents/mcp-server" + ] + } ] }, { @@ -307,6 +314,10 @@ ] }, "redirects": [ + { + "source": "/document-engine/mcp", + "destination": "/document-engine/ai-agents/mcp-server" + }, { "source": "/core/superdoc/properties", "destination": "/core/superdoc/methods#properties" diff --git a/apps/docs/document-engine/ai-agents/llm-tools.mdx b/apps/docs/document-engine/ai-agents/llm-tools.mdx new file mode 100644 index 0000000000..12ed982943 --- /dev/null +++ b/apps/docs/document-engine/ai-agents/llm-tools.mdx @@ -0,0 +1,363 @@ +--- +title: LLM Tools +sidebarTitle: LLM Tools +tag: NEW +description: Give LLMs structured access to document operations with provider-ready tool definitions and dispatch +keywords: "llm tools, ai document editing, openai tools, anthropic tools, tool use, function calling, superdoc sdk, document automation" +--- + +The SuperDoc SDK ships tool definitions that plug directly into OpenAI, Anthropic, Vercel AI, or any custom LLM integration. Pick tools, send them with your prompt, dispatch the model's tool calls — the SDK handles schema formatting, argument validation, and execution. + + +LLM tools are in alpha. Tool names and schemas may change between releases. + + +## Quick start + +Install the SDK, create a client, and wire up an agentic loop. + + + + ```bash + npm install @superdoc-dev/sdk openai + ``` + + ```typescript + import { createSuperDocClient, chooseTools, dispatchSuperDocTool } from '@superdoc-dev/sdk'; + import OpenAI from 'openai'; + + const client = createSuperDocClient(); + await client.connect(); + await client.doc.open({ doc: './contract.docx' }); + + const { tools } = await chooseTools({ provider: 'openai' }); + const openai = new OpenAI(); + + const messages = [ + { role: 'system', content: 'You edit documents using the provided tools.' }, + { role: 'user', content: 'Find the termination clause and rewrite it to allow 30-day notice.' }, + ]; + + // Agentic loop + while (true) { + const response = await openai.chat.completions.create({ + model: 'gpt-5.2', + messages, + tools, + }); + + const message = response.choices[0].message; + messages.push(message); + + if (!message.tool_calls?.length) break; + + for (const call of message.tool_calls) { + const result = await dispatchSuperDocTool( + client, call.function.name, JSON.parse(call.function.arguments), + ); + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); + } + } + + await client.doc.save({ inPlace: true }); + await client.dispose(); + ``` + + + ```bash + pip install superdoc-sdk openai + ``` + + ```python + from superdoc import SuperDocClient, choose_tools, dispatch_superdoc_tool + import openai, json + + client = SuperDocClient() + client.connect() + client.doc.open(doc="./contract.docx") + + result = choose_tools(provider="openai") + tools = result["tools"] + + messages = [ + {"role": "system", "content": "You edit documents using the provided tools."}, + {"role": "user", "content": "Find the termination clause and rewrite it to allow 30-day notice."}, + ] + + while True: + response = openai.chat.completions.create( + model="gpt-5.2", messages=messages, tools=tools + ) + message = response.choices[0].message + messages.append(message) + + if not message.tool_calls: + break + + for call in message.tool_calls: + result = dispatch_superdoc_tool( + client, call.function.name, json.loads(call.function.arguments) + ) + messages.append({ + "role": "tool", + "tool_call_id": call.id, + "content": json.dumps(result), + }) + + client.doc.save(in_place=True) + client.dispose() + ``` + + + +## Tool selection + +`chooseTools()` returns provider-formatted tool definitions ready to pass to your LLM. + + + + ```typescript + import { chooseTools } from '@superdoc-dev/sdk'; + + const { tools, selected, meta } = await chooseTools({ + provider: 'openai', // 'openai' | 'anthropic' | 'vercel' | 'generic' + mode: 'essential', // 'essential' (default) | 'all' + groups: ['comments'], // optional — load additional groups alongside essential tools + }); + ``` + + + ```python + from superdoc import choose_tools + + result = choose_tools( + provider="openai", + mode="essential", + groups=["comments"], + ) + tools = result["tools"] + ``` + + + +### Essential mode (default) + +Returns 5 essential tools plus `discover_tools` — a meta-tool that lets the LLM load more groups on demand. This keeps the initial context small while giving the model access to the full toolkit when needed. + +The 5 essential tools: + +| Tool | What it does | +| --- | --- | +| `get_document_text` | Returns the full plain-text content of the document | +| `query_match` | Searches by node type, text pattern, or both — returns matches with addresses | +| `apply_mutations` | Batch edit: rewrite, insert, delete text and apply formatting in one call | +| `get_node_by_id` | Get details about a specific node by its address | +| `undo` | Undo the last operation | + +If you pass `groups`, those groups are loaded **in addition** to the essential set: + +```typescript +// Essential tools + all comment tools +const { tools } = await chooseTools({ + provider: 'openai', + groups: ['comments'], +}); +``` + +### All mode + +Returns every tool from the requested groups (or all groups if `groups` is omitted). The `core` group is always included. + +```typescript +const { tools } = await chooseTools({ + provider: 'openai', + mode: 'all', + groups: ['core', 'format', 'comments'], +}); +``` + +## Dispatching tool calls + +`dispatchSuperDocTool()` resolves a tool name to the correct SDK method, validates arguments, and executes the call. + + + + ```typescript + import { dispatchSuperDocTool } from '@superdoc-dev/sdk'; + + const result = await dispatchSuperDocTool(client, toolName, args); + ``` + + + ```python + from superdoc import dispatch_superdoc_tool + + result = dispatch_superdoc_tool(client, tool_name, args) + ``` + + + ```python + from superdoc import dispatch_superdoc_tool_async + + result = await dispatch_superdoc_tool_async(client, tool_name, args) + ``` + + + +The dispatcher validates required parameters, enforces mutual exclusivity constraints, and throws descriptive errors if arguments are invalid — so the LLM gets actionable feedback. + +## Tool groups + +Tools are organized into 11 groups. In essential mode, the LLM can load any group dynamically via `discover_tools`. + +| Group | Description | +| --- | --- | +| `core` | Read nodes, get text, find/replace, insert, delete, batch mutations | +| `format` | Bold, italic, underline, strikethrough, alignment, spacing, borders, shading | +| `create` | Create headings, paragraphs, tables, sections, table of contents | +| `tables` | Row/column operations, cell merging, table formatting, borders | +| `sections` | Page layout, margins, columns, headers/footers, page numbering | +| `lists` | Bullet and numbered lists, indentation, list type conversion | +| `comments` | Create, edit, delete, resolve, and list comment threads | +| `trackChanges` | List, inspect, accept, and reject tracked changes | +| `toc` | Table of contents — create, configure, refresh | +| `history` | Undo and redo | +| `session` | Open, save, close, and manage document sessions | + +## The discover_tools pattern + +When the LLM needs tools beyond the essential set, it calls `discover_tools` with the groups it wants. Your agentic loop handles this like any other tool call — `dispatchSuperDocTool` returns the new tool definitions, and you merge them into the next request. + +```typescript +for (const call of message.tool_calls) { + const result = await dispatchSuperDocTool( + client, call.function.name, JSON.parse(call.function.arguments), + ); + + // discover_tools returns new tool definitions — merge them + if (call.function.name === 'discover_tools') { + tools.push(...result.tools); + } + + messages.push({ + role: 'tool', + tool_call_id: call.id, + content: JSON.stringify(result), + }); +} +``` + +## Providers + +Each provider gets tool definitions in its native format. + + + + ```typescript + const { tools } = await chooseTools({ provider: 'openai' }); + // [{ type: 'function', function: { name, description, parameters } }] + ``` + + + ```typescript + const { tools } = await chooseTools({ provider: 'anthropic' }); + // [{ name, description, input_schema }] + ``` + + + ```typescript + const { tools } = await chooseTools({ provider: 'vercel' }); + // [{ type: 'function', function: { name, description, parameters } }] + ``` + + + ```typescript + const { tools } = await chooseTools({ provider: 'generic' }); + // [{ name, description, parameters, returns, metadata }] + ``` + + + +## Best practices + +### Start with essential mode + +Load only the 5 essential tools plus `discover_tools`. This keeps the context window small and gives the model room to reason. Let it call `discover_tools` when it needs more — don't front-load every group. + +### Minimize tool calls + +A typical edit should take 3–5 tool calls: query, mutate, done. Instruct the LLM to plan all edits before calling tools, and to batch multiple changes into a single `apply_mutations` call when possible. + +### Use `apply_mutations` for text edits + +`apply_mutations` can rewrite, insert, delete, and format text in one call. It supports multiple steps, so the LLM can edit several paragraphs at once. Use it for any operation on existing text. + +### Feed errors back to the model + +`dispatchSuperDocTool` throws descriptive errors with codes like `MATCH_NOT_FOUND` or `INVALID_ARGUMENT`. Pass these back as tool results — most models self-correct on the next turn. + +### Add tool call examples for repeatable actions + +If your workflow involves the same kind of edit across many documents (e.g., always rewriting a specific clause, always adding a comment to a section), include a concrete tool call example in your system prompt. Models that see a working example of the exact tool invocation produce correct calls more reliably than models that only see the schema. + +### Include a system prompt + +Tell the model what it can do and how to approach edits. Here's an example: + +````markdown +You edit `.docx` files using SuperDoc tools. Be efficient — minimize tool calls. + +## Workflow + +1. **Query** — Call `query_match` to find text you want to edit. + Use `require: "any"` to match broadly. +2. **Plan** — Decide what changes to make. Think through all + edits before making tool calls. +3. **Mutate** — Call `apply_mutations` to rewrite, insert, delete, + or format text. + +Keep it to 3–5 tool calls total. + +## Tools + +You start with 5 essential tools plus `discover_tools`. +Call `discover_tools` to load additional categories when needed: + +| Category | What it covers | +|----------|----------------| +| core | Read nodes, get text, query, mutations | +| format | Text formatting, alignment, spacing, borders | +| create | Headings, paragraphs, tables, sections | +| tables | Table manipulation, cell operations | +| comments | Comment threads | +| trackChanges | Accept/reject tracked changes | + +## Rules + +- Use `apply_mutations` for text edits on existing content. + You can batch multiple rewrites in one call. +- Use standalone tools (`create_heading`, `create_table`, etc.) + for structural changes — these are NOT step ops. +- Set `changeMode: "tracked"` when edits need human review. +- Don't rewrite and format in the same `apply_mutations` batch. + Rewrite first, query again for fresh refs, then format. +```` + +## Utility functions + +| Function | Description | +| --- | --- | +| `chooseTools(input)` | Select tools for a provider, filtered by mode and groups | +| `dispatchSuperDocTool(client, name, args)` | Execute a tool call against a connected client | +| `listTools(provider)` | List all tool definitions for a provider | +| `resolveToolOperation(toolName)` | Map a tool name to its operation ID | +| `getToolCatalog()` | Load the full tool catalog with metadata | +| `getAvailableGroups()` | List all available tool groups | + +## Related + +- [MCP Server](/document-engine/ai-agents/mcp-server) — connect AI agents via the Model Context Protocol +- [Skills](/document-engine/ai-agents/skills) — reusable prompt templates for LLM document editing +- [SDKs](/document-engine/sdks) — typed Node.js and Python wrappers +- [Document API](/document-api/overview) — the operation set behind the tools +- [AI Agents](/getting-started/ai-agents) — headless mode for server-side AI workflows diff --git a/apps/docs/document-engine/mcp.mdx b/apps/docs/document-engine/ai-agents/mcp-server.mdx similarity index 98% rename from apps/docs/document-engine/mcp.mdx rename to apps/docs/document-engine/ai-agents/mcp-server.mdx index f8c128eeb3..ed1383123c 100644 --- a/apps/docs/document-engine/mcp.mdx +++ b/apps/docs/document-engine/ai-agents/mcp-server.mdx @@ -188,6 +188,7 @@ This opens a browser UI where you can call each tool manually and inspect the ra ## Related +- [LLM Tools](/document-engine/ai-agents/llm-tools) — build custom LLM integrations with the SDK - [CLI](/document-engine/cli) — edit documents from the terminal - [SDKs](/document-engine/sdks) — typed Node.js and Python wrappers - [Document API](/document-api/overview) — the in-browser API that defines the operation set diff --git a/apps/docs/document-engine/ai-agents/skills.mdx b/apps/docs/document-engine/ai-agents/skills.mdx new file mode 100644 index 0000000000..77ec6e4d4b --- /dev/null +++ b/apps/docs/document-engine/ai-agents/skills.mdx @@ -0,0 +1,18 @@ +--- +title: Skills +sidebarTitle: Skills +tag: SOON +description: Reusable prompt templates that teach LLMs how to edit documents with SuperDoc tools +keywords: "llm skills, prompt templates, ai document editing, superdoc skills" +--- + +Skills are reusable prompt files that teach LLMs how to use SuperDoc tools effectively. A skill contains editing instructions, tool usage patterns, and best practices — so your LLM agent knows how to query, mutate, and format documents without trial and error. + + +Skills are coming soon. Check back for updates. + + +## Related + +- [LLM Tools](/document-engine/ai-agents/llm-tools) — the SDK functions that power tool dispatch +- [MCP Server](/document-engine/ai-agents/mcp-server) — connect AI agents via the Model Context Protocol diff --git a/apps/docs/getting-started/ai-agents.mdx b/apps/docs/getting-started/ai-agents.mdx index 956ec78d72..0a58ec80d2 100644 --- a/apps/docs/getting-started/ai-agents.mdx +++ b/apps/docs/getting-started/ai-agents.mdx @@ -4,6 +4,10 @@ sidebarTitle: AI Agents ✨ keywords: "ai document editing, llm docx, headless editor, document automation ai, programmatic word editing" --- + +This page covers the low-level headless Editor approach. For production AI agent workflows, use the [Document Engine AI tools](/document-engine/ai-agents/llm-tools) instead — they provide structured tool definitions, provider-ready schemas, and built-in dispatch. + + SuperDoc can run headless in Node.js for server-side document processing, AI agent workflows, and batch automation. ## Quick example