From 8e768fc355b25bf93fdf2556dddf02e604b06664 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 27 May 2026 23:10:25 +0200 Subject: [PATCH 1/2] feat(sdk): add LLM tools preset registry (SD-3128) - Added validation for the MCP_PRESET environment variable in `server.ts` to ensure only supported presets are accepted at startup, preventing silent misconfigurations. - Introduced a new preset registry in `presets.ts`, allowing for the management of LLM tool presets, with the initial implementation supporting only the 'legacy' preset. - Updated the Node SDK to expose preset-related functions (`getPreset`, `listPresets`, `DEFAULT_PRESET`) for easier access to preset information. - Created tests for preset validation and registry functionality, ensuring that unknown presets trigger appropriate errors and that the legacy preset behaves as expected. - Added corresponding Python SDK support for the preset registry, mirroring the Node implementation for consistency across languages. --- .../src/__tests__/server-preset-env.test.ts | 59 +++ apps/mcp/src/server.ts | 12 + .../src/__tests__/cross-lang-parity.test.ts | 39 +- .../langs/node/src/__tests__/presets.test.ts | 146 ++++++ packages/sdk/langs/node/src/index.ts | 4 + packages/sdk/langs/node/src/presets.ts | 158 +++++++ packages/sdk/langs/node/src/presets/legacy.ts | 353 ++++++++++++++ packages/sdk/langs/node/src/tools.ts | 443 ++++-------------- packages/sdk/langs/python/pyproject.toml | 1 + .../sdk/langs/python/superdoc/__init__.py | 4 + .../langs/python/superdoc/presets/__init__.py | 102 ++++ .../langs/python/superdoc/presets/legacy.py | 278 +++++++++++ .../python/superdoc/test_parity_helper.py | 7 + .../sdk/langs/python/superdoc/tools_api.py | 247 +++------- .../sdk/langs/python/tests/test_presets.py | 137 ++++++ 15 files changed, 1460 insertions(+), 530 deletions(-) create mode 100644 apps/mcp/src/__tests__/server-preset-env.test.ts create mode 100644 packages/sdk/langs/node/src/__tests__/presets.test.ts create mode 100644 packages/sdk/langs/node/src/presets.ts create mode 100644 packages/sdk/langs/node/src/presets/legacy.ts create mode 100644 packages/sdk/langs/python/superdoc/presets/__init__.py create mode 100644 packages/sdk/langs/python/superdoc/presets/legacy.py create mode 100644 packages/sdk/langs/python/tests/test_presets.py diff --git a/apps/mcp/src/__tests__/server-preset-env.test.ts b/apps/mcp/src/__tests__/server-preset-env.test.ts new file mode 100644 index 0000000000..456bae7f7c --- /dev/null +++ b/apps/mcp/src/__tests__/server-preset-env.test.ts @@ -0,0 +1,59 @@ +/** + * MCP_PRESET env var selects which LLM-tools preset the server registers. + * Currently only 'legacy' is supported. Unknown preset ids must fail fast at + * startup so misconfiguration is visible instead of silently falling back to + * the default. + */ + +import { describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(import.meta.dir, '../../../..'); +const MCP_ENTRY = path.join(REPO_ROOT, 'apps/mcp/src/server.ts'); + +type RunResult = { code: number | null; stderr: string }; + +function runServer(env: NodeJS.ProcessEnv, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + const proc = spawn('bun', ['run', MCP_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderr = ''; + proc.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + // The MCP server runs forever waiting on stdio. We only care about whether + // it exits fast (rejecting bad preset id) or stays alive (accepting preset). + // For the success case we kill after a short window. + const timer = setTimeout(() => { + proc.kill('SIGTERM'); + }, timeoutMs); + + proc.on('close', (code) => { + clearTimeout(timer); + resolve({ code, stderr }); + }); + }); +} + +describe('MCP_PRESET env var', () => { + test('unknown preset id fails fast with exit code 2', async () => { + const result = await runServer({ MCP_PRESET: 'definitely-not-a-preset' }); + expect(result.code).toBe(2); + expect(result.stderr).toContain('unknown preset'); + expect(result.stderr).toContain('definitely-not-a-preset'); + expect(result.stderr).toContain('legacy'); + }); + + test('explicit MCP_PRESET=legacy is accepted (server stays alive)', async () => { + const result = await runServer({ MCP_PRESET: 'legacy' }); + // Server should still be running when we kill it (SIGTERM → code is null + // or signal-derived non-2). Either way, it must NOT exit with 2. + expect(result.code).not.toBe(2); + }); +}); diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index d771ac6111..b2e6376971 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -9,6 +9,18 @@ import { registerAllTools } from './tools/index.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json'); +// Validate MCP_PRESET at startup so misconfiguration fails fast instead of +// silently falling back to 'legacy'. Tool registration is wired to legacy via +// the static MCP_TOOL_CATALOG + dispatchIntentTool imports in tools/intent.ts; +// the resolved id is not plumbed further yet. When a non-legacy preset lands, +// pass the id into registerAllTools() so it can route through the registry. +const PRESETS_SUPPORTED = new Set(['legacy']); +const requestedPreset = process.env.MCP_PRESET ?? 'legacy'; +if (!PRESETS_SUPPORTED.has(requestedPreset)) { + console.error(`SuperDoc MCP: unknown preset "${requestedPreset}". Supported: ${[...PRESETS_SUPPORTED].join(', ')}.`); + process.exit(2); +} + const server = new McpServer( { name: 'superdoc', 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 203d7ef5dc..dcd13528ae 100644 --- a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts +++ b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts @@ -54,7 +54,7 @@ function callPython(command: Record): Promise { /** Import Node SDK chooseTools (cached). */ let _nodeTools: typeof import('../../../langs/node/src/tools.js') | null = null; -async function nodeTools() { +async function nodeTools(): Promise { if (!_nodeTools) { _nodeTools = await import(path.join(REPO_ROOT, 'packages/sdk/langs/node/src/tools.ts')); } @@ -78,6 +78,43 @@ describe('chooseTools parity', () => { expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount); expect(nodeResult.meta.toolCount).toBeGreaterThan(0); }); + + test('returns same tool count when preset: legacy is explicit (parity)', async () => { + const input = { provider: 'generic' as const, preset: 'legacy' }; + + const { chooseTools } = await nodeTools(); + const nodeResult = await chooseTools(input); + + const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult & { + meta: { preset?: string }; + }; + + expect(pyResult.meta.provider).toBe(nodeResult.meta.provider); + expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount); + expect(pyResult.meta.preset).toBe('legacy'); + expect(nodeResult.meta.preset).toBe('legacy'); + }); +}); + +// -------------------------------------------------------------------------- +// Preset registry parity +// -------------------------------------------------------------------------- + +describe('Preset registry parity', () => { + test('Node and Python expose the same DEFAULT_PRESET and registered ids', async () => { + const { DEFAULT_PRESET: nodeDefault, listPresets: nodeList } = await nodeTools(); + const nodePresets = nodeList(); + + const pyResult = (await callPython({ action: 'listPresets' })) as { + defaultPreset: string; + presets: string[]; + }; + + expect(pyResult.defaultPreset).toBe(nodeDefault); + expect(nodeDefault).toBe('legacy'); + // Both runtimes register the same preset set (order-agnostic). + expect([...pyResult.presets].sort()).toEqual([...nodePresets].sort()); + }); }); // -------------------------------------------------------------------------- diff --git a/packages/sdk/langs/node/src/__tests__/presets.test.ts b/packages/sdk/langs/node/src/__tests__/presets.test.ts new file mode 100644 index 0000000000..b789e14738 --- /dev/null +++ b/packages/sdk/langs/node/src/__tests__/presets.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import { + chooseTools, + DEFAULT_PRESET, + getPreset, + getMcpPrompt, + getSystemPrompt, + getSystemPromptForProvider, + getToolCatalog, + listPresets, + listTools, +} from '../tools.ts'; +import { SuperDocCliError } from '../runtime/errors.js'; + +const PROVIDERS = ['openai', 'anthropic', 'vercel', 'generic'] as const; + +describe('preset registry', () => { + test('DEFAULT_PRESET is "legacy"', () => { + expect(DEFAULT_PRESET).toBe('legacy'); + }); + + test('listPresets() includes "legacy"', () => { + const presets = listPresets(); + expect(presets).toContain('legacy'); + }); + + test('getPreset() (no arg) returns the legacy preset', () => { + const preset = getPreset(); + expect(preset.id).toBe('legacy'); + }); + + test('getPreset("legacy") returns the legacy preset', () => { + const preset = getPreset('legacy'); + expect(preset.id).toBe('legacy'); + expect(preset.description).toBeDefined(); + expect(preset.supportsCacheControl).toBe(true); + }); + + test('getPreset("nonexistent") throws PRESET_NOT_FOUND', () => { + try { + getPreset('nonexistent-preset'); + throw new Error('Expected getPreset to throw.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + const cliError = error as SuperDocCliError; + expect(cliError.code).toBe('PRESET_NOT_FOUND'); + expect(cliError.message).toContain('nonexistent-preset'); + const details = cliError.details as { id: string; availablePresets: string[] }; + expect(details.id).toBe('nonexistent-preset'); + expect(details.availablePresets).toContain('legacy'); + } + }); +}); + +describe('chooseTools — default preset equivalence', () => { + for (const provider of PROVIDERS) { + test(`omitting preset equals preset: 'legacy' (${provider})`, async () => { + const implicit = await chooseTools({ provider }); + const explicit = await chooseTools({ provider, preset: 'legacy' }); + // Tools content identical + expect(implicit.tools).toEqual(explicit.tools); + // Same tool count + expect(implicit.meta.toolCount).toBe(explicit.meta.toolCount); + // Same provider, same cache strategy + expect(implicit.meta.provider).toBe(explicit.meta.provider); + expect(implicit.meta.cacheStrategy).toBe(explicit.meta.cacheStrategy); + // Both echo legacy as resolved preset + expect(implicit.meta.preset).toBe('legacy'); + expect(explicit.meta.preset).toBe('legacy'); + }); + } + + test(`chooseTools(provider, preset: 'nonexistent') throws PRESET_NOT_FOUND`, async () => { + await expect(chooseTools({ provider: 'openai', preset: 'nonexistent-preset' })).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); + + test('meta.preset field is included', async () => { + const { meta } = await chooseTools({ provider: 'openai' }); + expect(meta.preset).toBe('legacy'); + }); +}); + +describe('catalog + listings — default preset equivalence', () => { + test(`getToolCatalog() equals getToolCatalog('legacy')`, async () => { + const implicit = await getToolCatalog(); + const explicit = await getToolCatalog('legacy'); + expect(implicit).toEqual(explicit); + }); + + for (const provider of PROVIDERS) { + test(`listTools(${provider}) equals listTools(${provider}, 'legacy')`, async () => { + const implicit = await listTools(provider); + const explicit = await listTools(provider, 'legacy'); + expect(implicit).toEqual(explicit); + }); + } + + test(`getToolCatalog('nonexistent') throws PRESET_NOT_FOUND`, async () => { + await expect(getToolCatalog('nonexistent-preset')).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); +}); + +describe('system prompts — default preset equivalence', () => { + test(`getSystemPrompt() equals getSystemPrompt('legacy')`, async () => { + const implicit = await getSystemPrompt(); + const explicit = await getSystemPrompt('legacy'); + expect(implicit).toBe(explicit); + }); + + test(`getMcpPrompt() equals getMcpPrompt('legacy')`, async () => { + const implicit = await getMcpPrompt(); + const explicit = await getMcpPrompt('legacy'); + expect(implicit).toBe(explicit); + }); + + test(`getSystemPromptForProvider({provider}) equals preset: 'legacy'`, async () => { + const implicit = await getSystemPromptForProvider({ provider: 'anthropic', cache: true }); + const explicit = await getSystemPromptForProvider({ + provider: 'anthropic', + preset: 'legacy', + cache: true, + }); + expect(implicit).toEqual(explicit); + }); +}); + +describe('legacy preset direct access', () => { + test('getPreset("legacy").getCatalog() matches getToolCatalog()', async () => { + const direct = await getPreset('legacy').getCatalog(); + const viaTopLevel = await getToolCatalog(); + expect(direct).toEqual(viaTopLevel); + }); + + for (const provider of PROVIDERS) { + test(`getPreset("legacy").getTools(${provider}) matches chooseTools({provider}).tools`, async () => { + const direct = await getPreset('legacy').getTools(provider); + const viaTopLevel = await chooseTools({ provider }); + expect(direct.tools).toEqual(viaTopLevel.tools); + expect(direct.cacheStrategy).toBe(viaTopLevel.meta.cacheStrategy); + }); + } +}); diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index b3e1e8b25a..68b33aee19 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -253,11 +253,15 @@ export { getSystemPromptForProvider, getToolCatalog, listTools, + DEFAULT_PRESET, + getPreset, + listPresets, } from './tools.js'; export type { AnthropicSystemPrompt, CacheStrategy, SystemPromptForProviderResult, + ToolCatalog, ToolChooserInput, ToolProvider, } from './tools.js'; diff --git a/packages/sdk/langs/node/src/presets.ts b/packages/sdk/langs/node/src/presets.ts new file mode 100644 index 0000000000..b3633dbfb3 --- /dev/null +++ b/packages/sdk/langs/node/src/presets.ts @@ -0,0 +1,158 @@ +/** + * Preset registry for SuperDoc LLM tools. + * + * A preset is a self-contained collection of LLM tools — provider catalogs + * (openai / anthropic / vercel / generic), a system prompt, and a dispatcher. + * Multiple presets can coexist in the SDK; consumers select one at runtime via + * `chooseTools({ preset })`. + * + * const { tools, meta } = await chooseTools({ provider: 'vercel', preset: 'legacy' }); + * + * v1 ships a single preset: `'legacy'` — a thin wrapper around today's + * codegen-emitted intent tools. When callers omit `preset`, `legacy` is used. + * The default may move once a replacement preset reaches parity; bumping it is + * a coordinated change in this file alone. + * + * Presets are NOT versioned. The preset id encodes the variant; a new shape + * ships as a new id, not a new version of an existing one. + * + * @internal + */ + +import type { BoundDocApi } from './generated/client.js'; +import type { InvokeOptions } from './runtime/process.js'; +import { SuperDocCliError } from './runtime/errors.js'; +import { legacyPreset } from './presets/legacy.js'; + +/** + * Wire format the tools are emitted in. + * + * - `openai` — OpenAI Chat Completions / Responses + * - `anthropic` — Anthropic Messages API + * - `vercel` — Vercel AI SDK (provider-agnostic adapter) + * - `generic` — vendor-neutral JSON Schema shape + */ +export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; + +/** + * Prompt-cache strategy returned by `chooseTools.meta.cacheStrategy`. + * + * - `explicit` — preset emitted provider-specific cache markers (Anthropic `cache_control`) + * - `automatic` — provider caches automatically (OpenAI ≥ 1024 prompt tokens) + * - `unsupported` — pass-through; caching depends on the underlying model (vercel/generic) + * - `disabled` — caller passed `cache: false` or omitted the flag + */ +export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; + +/** + * Full tool catalog shape. The legacy preset returns the existing codegen + * catalog with `contractVersion`, `generatedAt`, `toolCount`, `tools`. + */ +export type ToolCatalog = { + contractVersion: string; + generatedAt: string | null; + toolCount: number; + tools: unknown[]; +}; + +export interface GetToolsOptions { + /** + * When `true`, the preset applies provider-specific prompt-cache markers + * (Anthropic `cache_control: { type: "ephemeral" }` on the last tool, + * for example). When omitted or `false`, no markers are added. + */ + cache?: boolean; +} + +export interface GetToolsResult { + tools: unknown[]; + cacheStrategy: CacheStrategy; +} + +/** + * Self-contained preset of LLM tools. + * + * Each preset owns: + * - its tool catalogs per provider format + * - its system prompt (and MCP-flavored variant) + * - its dispatcher (how a named tool call routes against a doc handle) + * + * Presets are stateless; the same descriptor handles every call. + * + * @internal + */ +export interface PresetDescriptor { + /** Stable identifier — used as the preset's only "version" reference. */ + readonly id: string; + + /** Human-readable description shown by `listPresets()`. */ + readonly description: string; + + /** + * Whether this preset's provider adapters emit Anthropic prompt-cache + * markers when called with `cache: true`. Informational; per-provider + * behavior is reported via `GetToolsResult.cacheStrategy`. + */ + readonly supportsCacheControl: boolean; + + /** Tool definitions for the requested provider format. */ + getTools(provider: ToolProvider, options?: GetToolsOptions): Promise; + + /** Full tool catalog with metadata (contract version, tool count, etc.). */ + getCatalog(): Promise; + + /** System prompt for embedded LLM usage (OpenAI/Anthropic/Vercel APIs). */ + getSystemPrompt(): Promise; + + /** System prompt for MCP server `instructions`. */ + getMcpPrompt(): Promise; + + /** + * Dispatch a tool call against a bound document handle. + * + * The handle injects session targeting; `args` must NOT carry `doc` or + * `sessionId`. Returns whatever the underlying operation produces. + */ + dispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** + * The default preset returned when callers omit `preset`. Set to `'legacy'` + * so consumers built before presets existed (today's intent-tool path) keep + * working without changes. + */ +export const DEFAULT_PRESET = 'legacy'; + +const PRESETS: Record = { + legacy: legacyPreset, +}; + +/** List the IDs of all registered presets. */ +export function listPresets(): readonly string[] { + return Object.keys(PRESETS); +} + +/** + * Resolve a preset by ID. Throws {@link SuperDocCliError} with code + * `PRESET_NOT_FOUND` if the ID is not registered. Omit the argument to + * get the default preset. + */ +export function getPreset(id: string = DEFAULT_PRESET): PresetDescriptor { + const preset = PRESETS[id]; + if (preset == null) { + throw new SuperDocCliError(`Unknown LLM-tools preset: "${id}"`, { + code: 'PRESET_NOT_FOUND', + details: { id, availablePresets: Object.keys(PRESETS) }, + }); + } + return preset; +} diff --git a/packages/sdk/langs/node/src/presets/legacy.ts b/packages/sdk/langs/node/src/presets/legacy.ts new file mode 100644 index 0000000000..a8336fa755 --- /dev/null +++ b/packages/sdk/langs/node/src/presets/legacy.ts @@ -0,0 +1,353 @@ +/** + * Legacy preset — wraps the existing codegen-emitted intent tools verbatim. + * + * The legacy preset is a read-through over the packaged tool artifacts in + * `packages/sdk/tools/` (catalog, per-provider tool JSON, system prompts) and + * delegates dispatch to the codegen-emitted `dispatchIntentTool`. It is the + * default preset returned by `chooseTools()` when callers omit `preset`. + * + * Nothing in this file relocates or rewrites the packaged artifacts. The whole + * point of the read-through wrapper is that running `generate:all` continues + * to refresh `packages/sdk/tools/*.json` in place; the legacy preset picks up + * the new files on the next call. + * + * @internal + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { BoundDocApi } from '../generated/client.js'; +import type { InvokeOptions } from '../runtime/process.js'; +import { SuperDocCliError } from '../runtime/errors.js'; +import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js'; +import type { PresetDescriptor, GetToolsOptions, GetToolsResult, ToolCatalog, ToolProvider } from '../presets.js'; + +// Resolve tools directory relative to package root (works from both src/ and dist/) +const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools'); + +const providerFileByName: Record = { + openai: 'tools.openai.json', + anthropic: 'tools.anthropic.json', + vercel: 'tools.vercel.json', + generic: 'tools.generic.json', +}; + +type OperationEntry = { + operationId: string; + intentAction: string; + required?: string[]; + requiredOneOf?: string[][]; +}; + +type ToolCatalogEntry = { + toolName: string; + description: string; + inputSchema: Record; + mutates: boolean; + operations: OperationEntry[]; +}; + +type LegacyToolCatalog = ToolCatalog & { tools: ToolCatalogEntry[] }; + +const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null && !Array.isArray(value); +} + +function isObviouslyCorruptedToolArgKey(key: string): boolean { + const trimmed = key.trim(); + return trimmed.length === 0 || !/[\p{L}\p{N}]/u.test(trimmed); +} + +function stripCorruptedToolArgKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stripCorruptedToolArgKeys(item)); + } + if (!isRecord(value)) return value; + const clean: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (isObviouslyCorruptedToolArgKey(key)) continue; + clean[key] = stripCorruptedToolArgKeys(entryValue); + } + return clean; +} + +async function readJson(fileName: string): Promise { + const filePath = path.join(toolsDir, fileName); + let raw = ''; + try { + raw = await readFile(filePath, 'utf8'); + } catch (error) { + throw new SuperDocCliError('Unable to load packaged tool artifact.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath, message: error instanceof Error ? error.message : String(error) }, + }); + } + try { + return JSON.parse(raw) as T; + } catch (error) { + throw new SuperDocCliError('Packaged tool artifact is invalid JSON.', { + code: 'TOOLS_ASSET_INVALID', + details: { filePath, message: error instanceof Error ? error.message : String(error) }, + }); + } +} + +async function readProviderTools(provider: ToolProvider): Promise<{ + contractVersion: string; + tools: unknown[]; +}> { + return readJson(providerFileByName[provider]); +} + +// Cached catalog instance — loaded once per process. +let _catalogCache: LegacyToolCatalog | null = null; + +async function getCachedCatalog(): Promise { + if (_catalogCache == null) { + _catalogCache = await readJson('catalog.json'); + } + return _catalogCache; +} + +/** + * Apply provider-specific caching markers to the tools array. Clones the last + * entry instead of mutating the input. Anthropic gets an explicit + * `cache_control` on the last tool; other providers pass through. + */ +function applyCacheMarkers(tools: unknown[], provider: ToolProvider, cacheRequested: boolean): GetToolsResult { + if (!cacheRequested) { + return { tools, cacheStrategy: 'disabled' }; + } + + if (provider === 'anthropic') { + if (tools.length === 0) return { tools, cacheStrategy: 'explicit' }; + // Anthropic: marking the LAST tool with cache_control caches the entire + // tools block (and everything before it in the request — system prompt + // first if it also has cache_control). Shallow-spread the last entry so we + // don't mutate the cached tool list in place. + const next = tools.slice(0, -1); + const last = { + ...(tools[tools.length - 1] as Record), + cache_control: { type: 'ephemeral' }, + }; + next.push(last); + return { tools: next, cacheStrategy: 'explicit' }; + } + + if (provider === 'openai') { + // OpenAI caches prompts ≥ 1024 tokens automatically. No marker needed, + // but we still report cacheStrategy:'automatic' so callers can branch on + // it (e.g. for measurement). + return { tools, cacheStrategy: 'automatic' }; + } + + // vercel / generic — depends on underlying model. + return { tools, cacheStrategy: 'unsupported' }; +} + +function resolveDocApiMethod( + documentHandle: BoundDocApi, + operationId: string, +): (args: unknown, options?: InvokeOptions) => Promise { + const tokens = operationId.split('.').slice(1); + let cursor: unknown = documentHandle; + + for (const token of tokens) { + if (!isRecord(cursor) || !(token in cursor)) { + throw new SuperDocCliError(`No SDK doc method found for operation ${operationId}.`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { operationId, token }, + }); + } + cursor = cursor[token]; + } + + if (typeof cursor !== 'function') { + throw new SuperDocCliError(`Resolved member for ${operationId} is not callable.`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { operationId }, + }); + } + + return cursor as (args: unknown, options?: InvokeOptions) => Promise; +} + +/** + * Validate tool arguments against the catalog schema. + * + * Checks three things in order: + * 1. No unknown keys (additionalProperties: false in merged schema) + * 2. All universally-required keys present (merged schema `required`) + * 3. All action-specific required keys present (per-operation `required`) + */ +function validateToolArgs(toolName: string, args: Record, tool: ToolCatalogEntry): void { + const schema = tool.inputSchema; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required: string[] = Array.isArray(schema.required) ? (schema.required as string[]) : []; + + // 1. Reject unknown keys (additionalProperties: false in merged schema) + const knownKeys = new Set(Object.keys(properties)); + const unknownKeys = Object.keys(args).filter((k) => !knownKeys.has(k)); + if (unknownKeys.length > 0) { + throw new SuperDocCliError(`Unknown argument(s) for ${toolName}: ${unknownKeys.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { toolName, unknownKeys, knownKeys: [...knownKeys] }, + }); + } + + // 2. Reject missing universally-required keys (merged schema `required`) + const missingKeys = required.filter((k) => args[k] == null); + if (missingKeys.length > 0) { + throw new SuperDocCliError(`Missing required argument(s) for ${toolName}: ${missingKeys.join(', ')}`, { + code: 'INVALID_ARGUMENT', + details: { toolName, missingKeys }, + }); + } + + // 3. Reject missing per-operation required keys. For multi-action tools, + // resolve the operation by action; for single-op tools, use the sole entry. + const action = args.action; + let op: OperationEntry | undefined; + if (typeof action === 'string' && tool.operations.length > 1) { + op = tool.operations.find((o) => o.intentAction === action); + } else if (tool.operations.length === 1) { + op = tool.operations[0]; + } + + if (op) { + validateOperationRequired(toolName, action, args, op); + } +} + +/** + * Check per-operation required constraints. Handles both flat `required: string[]` + * and discriminated `requiredOneOf: string[][]` shapes emitted by codegen. + */ +function validateOperationRequired( + toolName: string, + action: unknown, + args: Record, + op: OperationEntry, +): void { + const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; + + if (op.requiredOneOf && op.requiredOneOf.length > 0) { + const satisfied = op.requiredOneOf.some((branch) => branch.every((k) => args[k] != null)); + if (!satisfied) { + const options = op.requiredOneOf.map((b) => b.join(' + ')).join(' | '); + throw new SuperDocCliError( + `Missing required argument(s) for ${toolName}${actionLabel}: must provide one of: ${options}`, + { + code: 'INVALID_ARGUMENT', + details: { toolName, action, requiredOneOf: op.requiredOneOf }, + }, + ); + } + } else if (op.required && op.required.length > 0) { + const missingActionKeys = op.required.filter((k) => args[k] == null); + if (missingActionKeys.length > 0) { + throw new SuperDocCliError( + `Missing required argument(s) for ${toolName}${actionLabel}: ${missingActionKeys.join(', ')}`, + { + code: 'INVALID_ARGUMENT', + details: { toolName, action, missingKeys: missingActionKeys }, + }, + ); + } + } +} + +async function legacyGetTools(provider: ToolProvider, options?: GetToolsOptions): Promise { + const { tools } = await readProviderTools(provider); + const rawTools = Array.isArray(tools) ? tools : []; + return applyCacheMarkers(rawTools, provider, options?.cache === true); +} + +async function legacyGetCatalog(): Promise { + return getCachedCatalog(); +} + +async function legacyGetSystemPrompt(): Promise { + const promptPath = path.join(toolsDir, 'system-prompt.md'); + try { + return await readFile(promptPath, 'utf8'); + } catch { + throw new SuperDocCliError('System prompt not found.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath: promptPath }, + }); + } +} + +async function legacyGetMcpPrompt(): Promise { + const promptPath = path.join(toolsDir, 'system-prompt-mcp.md'); + try { + return await readFile(promptPath, 'utf8'); + } catch { + throw new SuperDocCliError('MCP system prompt not found.', { + code: 'TOOLS_ASSET_NOT_FOUND', + details: { filePath: promptPath }, + }); + } +} + +async function legacyDispatch( + documentHandle: BoundDocApi, + toolName: string, + args: Record, + invokeOptions?: InvokeOptions, +): Promise { + if (!isRecord(args)) { + throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { toolName }, + }); + } + + const sanitizedArgs = stripCorruptedToolArgKeys(args); + if (!isRecord(sanitizedArgs)) { + throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { + code: 'INVALID_ARGUMENT', + details: { toolName }, + }); + } + + const catalog = await getCachedCatalog(); + const entries = catalog.tools as ToolCatalogEntry[]; + const tool = entries.find((t) => t.toolName === toolName); + if (tool == null) { + throw new SuperDocCliError(`Unknown tool: ${toolName}`, { + code: 'TOOL_DISPATCH_NOT_FOUND', + details: { toolName }, + }); + } + validateToolArgs(toolName, sanitizedArgs, tool); + + // Strip empty strings for known optional ID/enum params that LLMs fill with "" + // instead of omitting. Only target params where "" is never a valid value. + const cleanArgs: Record = {}; + for (const [key, value] of Object.entries(sanitizedArgs)) { + if (value === '' && STRIP_EMPTY_OPTIONAL_ARGS.has(key)) continue; + cleanArgs[key] = value; + } + + return dispatchIntentTool(toolName, cleanArgs, (operationId, input) => { + const method = resolveDocApiMethod(documentHandle, operationId); + return method(input, invokeOptions); + }); +} + +export const legacyPreset: PresetDescriptor = { + id: 'legacy', + description: 'Codegen-emitted intent tools (default). Wraps packages/sdk/tools/ artifacts verbatim.', + supportsCacheControl: true, + + getTools: legacyGetTools, + getCatalog: legacyGetCatalog, + getSystemPrompt: legacyGetSystemPrompt, + getMcpPrompt: legacyGetMcpPrompt, + dispatch: legacyDispatch, +}; diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index a7b80acd63..0c8240759e 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -1,127 +1,39 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** + * Public LLM-tools API. Thin layer over the preset registry — every call here + * resolves a preset (defaulting to `legacy` for backwards compat) and delegates + * to it. + * + * Presets are the unit of swapping. To add a new tool surface (e.g. handwritten + * "core" tools, prompt-caching variant, lazy-load experiment), register a new + * descriptor in `presets.ts` — no changes here required. + */ + import type { BoundDocApi } from './generated/client.js'; import type { InvokeOptions } from './runtime/process.js'; -import { SuperDocCliError } from './runtime/errors.js'; -import { dispatchIntentTool } from './generated/intent-dispatch.generated.js'; - -export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; - -// Resolve tools directory relative to package root (works from both src/ and dist/) -const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools'); -const providerFileByName: Record = { - openai: 'tools.openai.json', - anthropic: 'tools.anthropic.json', - vercel: 'tools.vercel.json', - generic: 'tools.generic.json', -}; - -export type ToolCatalog = { - contractVersion: string; - generatedAt: string | null; - toolCount: number; - tools: ToolCatalogEntry[]; -}; - -type OperationEntry = { - operationId: string; - intentAction: string; - required?: string[]; - requiredOneOf?: string[][]; -}; - -type ToolCatalogEntry = { - toolName: string; - description: string; - inputSchema: Record; - mutates: boolean; - operations: OperationEntry[]; -}; - -const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); +import { + DEFAULT_PRESET, + getPreset, + listPresets, + type CacheStrategy, + type ToolCatalog, + type ToolProvider, +} from './presets.js'; + +export { DEFAULT_PRESET, getPreset, listPresets }; +export type { CacheStrategy, ToolCatalog, ToolProvider }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value != null && !Array.isArray(value); -} - -function isObviouslyCorruptedToolArgKey(key: string): boolean { - const trimmed = key.trim(); - return trimmed.length === 0 || !/[\p{L}\p{N}]/u.test(trimmed); -} - -function stripCorruptedToolArgKeys(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => stripCorruptedToolArgKeys(item)); - } - - if (!isRecord(value)) return value; - - const clean: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - if (isObviouslyCorruptedToolArgKey(key)) continue; - clean[key] = stripCorruptedToolArgKeys(entryValue); - } - return clean; -} - -async function readJson(fileName: string): Promise { - const filePath = path.join(toolsDir, fileName); - let raw = ''; - try { - raw = await readFile(filePath, 'utf8'); - } catch (error) { - throw new SuperDocCliError('Unable to load packaged tool artifact.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { - filePath, - message: error instanceof Error ? error.message : String(error), - }, - }); - } - - try { - return JSON.parse(raw) as T; - } catch (error) { - throw new SuperDocCliError('Packaged tool artifact is invalid JSON.', { - code: 'TOOLS_ASSET_INVALID', - details: { - filePath, - message: error instanceof Error ? error.message : String(error), - }, - }); - } -} - -async function loadProviderBundle(provider: ToolProvider): Promise<{ - contractVersion: string; - tools: unknown[]; -}> { - return readJson(providerFileByName[provider]); -} - -async function loadCatalog(): Promise { - return readJson('catalog.json'); -} - -export async function getToolCatalog(): Promise { - return getCachedCatalog(); -} - -export async function listTools(provider: ToolProvider): Promise { - const bundle = await loadProviderBundle(provider); - const tools = bundle.tools; - if (!Array.isArray(tools)) { - throw new SuperDocCliError('Tool provider bundle is missing tools array.', { - code: 'TOOLS_ASSET_INVALID', - details: { provider }, - }); - } - return tools; -} +// --------------------------------------------------------------------------- +// chooseTools — provider-shaped tool list with optional cache markers +// --------------------------------------------------------------------------- export type ToolChooserInput = { provider: ToolProvider; + /** + * Preset ID to load tools from. Defaults to {@link DEFAULT_PRESET} + * (`'legacy'`) for backwards compatibility. Use {@link listPresets} to + * discover available presets. + */ + preset?: string; /** * When `true`, applies provider-specific prompt-caching markers to the * returned tools so subsequent identical requests reuse the cached prefix. @@ -139,220 +51,79 @@ export type ToolChooserInput = { cache?: boolean; }; -export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; - /** - * Select all intent tools for a specific provider. - * - * Returns all intent tools in the requested provider format. Pass - * `cache: true` to apply provider-specific caching markers (see - * {@link ToolChooserInput.cache}). + * Select tools for a specific provider from a preset. * * @example * ```ts + * // Default — legacy preset, no cache markers. + * const { tools, meta } = await chooseTools({ provider: 'vercel' }); + * * // Anthropic — last tool gets cache_control automatically. * const { tools, meta } = await chooseTools({ provider: 'anthropic', cache: true }); * - * // OpenAI — caching is automatic when prompts exceed 1024 tokens. - * const { tools } = await chooseTools({ provider: 'openai', cache: true }); + * // Pick a specific preset by ID. + * const { tools, meta } = await chooseTools({ provider: 'openai', preset: 'legacy' }); * ``` */ export async function chooseTools(input: ToolChooserInput): Promise<{ tools: unknown[]; meta: { provider: ToolProvider; + preset: string; toolCount: number; cacheStrategy: CacheStrategy; }; }> { - const bundle = await loadProviderBundle(input.provider); - const rawTools = Array.isArray(bundle.tools) ? bundle.tools : []; - const cacheRequested = input.cache === true; - - const { tools, cacheStrategy } = applyCacheMarkers(rawTools, input.provider, cacheRequested); - + const presetId = input.preset ?? DEFAULT_PRESET; + const preset = getPreset(presetId); + const { tools, cacheStrategy } = await preset.getTools(input.provider, { + cache: input.cache === true, + }); return { tools, meta: { provider: input.provider, + preset: presetId, toolCount: tools.length, cacheStrategy, }, }; } -/** - * Apply provider-specific caching markers to the tools array. Mutates a clone, - * never the input. Anthropic gets an explicit `cache_control` on the last - * tool; other providers pass through. - */ -function applyCacheMarkers( - tools: unknown[], - provider: ToolProvider, - cacheRequested: boolean, -): { tools: unknown[]; cacheStrategy: CacheStrategy } { - if (!cacheRequested) { - return { tools, cacheStrategy: 'disabled' }; - } - - if (provider === 'anthropic') { - if (tools.length === 0) return { tools, cacheStrategy: 'explicit' }; - // Anthropic: marking the LAST tool with cache_control caches the entire - // tools block (and everything before it in the request — system prompt - // first if it also has cache_control). Shallow-spread the last entry so we - // don't mutate the cached bundle in place. - const next = tools.slice(0, -1); - const last = { - ...(tools[tools.length - 1] as Record), - cache_control: { type: 'ephemeral' }, - }; - next.push(last); - return { tools: next, cacheStrategy: 'explicit' }; - } - - if (provider === 'openai') { - // OpenAI caches prompts ≥ 1024 tokens automatically. No marker needed, - // but we still report cacheStrategy:'automatic' so callers can branch on - // it (e.g. for measurement). - return { tools, cacheStrategy: 'automatic' }; - } - - // vercel / generic — depends on underlying model. - return { tools, cacheStrategy: 'unsupported' }; -} - -function resolveDocApiMethod( - documentHandle: BoundDocApi, - operationId: string, -): (args: unknown, options?: InvokeOptions) => Promise { - const tokens = operationId.split('.').slice(1); - let cursor: unknown = documentHandle; - - for (const token of tokens) { - if (!isRecord(cursor) || !(token in cursor)) { - throw new SuperDocCliError(`No SDK doc method found for operation ${operationId}.`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { operationId, token }, - }); - } - cursor = cursor[token]; - } - - if (typeof cursor !== 'function') { - throw new SuperDocCliError(`Resolved member for ${operationId} is not callable.`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { operationId }, - }); - } - - return cursor as (args: unknown, options?: InvokeOptions) => Promise; -} - -// Cached catalog instance — loaded once per process. -let _catalogCache: ToolCatalog | null = null; +// --------------------------------------------------------------------------- +// Catalog + listings (preset-scoped; default to legacy) +// --------------------------------------------------------------------------- -async function getCachedCatalog(): Promise { - if (_catalogCache == null) { - _catalogCache = await loadCatalog(); - } - return _catalogCache; +/** Return the full tool catalog for a preset (default: legacy). */ +export async function getToolCatalog(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getCatalog(); } /** - * Validate tool arguments against the catalog schema. + * Return the raw tool array for a provider from a preset (default: legacy). * - * Checks three things in order: - * 1. No unknown keys (additionalProperties: false in merged schema) - * 2. All universally-required keys present (merged schema `required`) - * 3. All action-specific required keys present (per-operation `required`) + * No cache markers are applied. Use {@link chooseTools} when you need cache + * markers and metadata. */ -function validateToolArgs(toolName: string, args: Record, tool: ToolCatalogEntry): void { - const schema = tool.inputSchema; - const properties = isRecord(schema.properties) ? schema.properties : {}; - const required: string[] = Array.isArray(schema.required) ? (schema.required as string[]) : []; - - // 1. Reject unknown keys - const knownKeys = new Set(Object.keys(properties)); - const unknownKeys = Object.keys(args).filter((k) => !knownKeys.has(k)); - if (unknownKeys.length > 0) { - throw new SuperDocCliError(`Unknown argument(s) for ${toolName}: ${unknownKeys.join(', ')}`, { - code: 'INVALID_ARGUMENT', - details: { toolName, unknownKeys, knownKeys: [...knownKeys] }, - }); - } - - // 2. Reject missing universally-required keys - const missingKeys = required.filter((k) => args[k] == null); - if (missingKeys.length > 0) { - throw new SuperDocCliError(`Missing required argument(s) for ${toolName}: ${missingKeys.join(', ')}`, { - code: 'INVALID_ARGUMENT', - details: { toolName, missingKeys }, - }); - } - - // 3. Reject missing per-operation required keys. - // For multi-action tools, resolve the operation by action; for single-op - // tools, use the sole operation entry. - const action = args.action; - let op: OperationEntry | undefined; - if (typeof action === 'string' && tool.operations.length > 1) { - op = tool.operations.find((o) => o.intentAction === action); - } else if (tool.operations.length === 1) { - op = tool.operations[0]; - } - - if (op) { - validateOperationRequired(toolName, action, args, op); - } +export async function listTools(provider: ToolProvider, preset?: string): Promise { + const { tools } = await getPreset(preset ?? DEFAULT_PRESET).getTools(provider, { cache: false }); + return tools; } -/** - * Check per-operation required constraints. - * - * Handles two shapes emitted by the codegen: - * - `required: string[]` — all listed keys must be present - * - `requiredOneOf: string[][]` — at least one branch must be fully satisfied - * (mirrors JSON Schema `oneOf` with per-branch `required` arrays) - */ -function validateOperationRequired( - toolName: string, - action: unknown, - args: Record, - op: OperationEntry, -): void { - const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; - - if (op.requiredOneOf && op.requiredOneOf.length > 0) { - const satisfied = op.requiredOneOf.some((branch) => branch.every((k) => args[k] != null)); - if (!satisfied) { - const options = op.requiredOneOf.map((b) => b.join(' + ')).join(' | '); - throw new SuperDocCliError( - `Missing required argument(s) for ${toolName}${actionLabel}: must provide one of: ${options}`, - { - code: 'INVALID_ARGUMENT', - details: { toolName, action, requiredOneOf: op.requiredOneOf }, - }, - ); - } - } else if (op.required && op.required.length > 0) { - const missingActionKeys = op.required.filter((k) => args[k] == null); - if (missingActionKeys.length > 0) { - throw new SuperDocCliError( - `Missing required argument(s) for ${toolName}${actionLabel}: ${missingActionKeys.join(', ')}`, - { - code: 'INVALID_ARGUMENT', - details: { toolName, action, missingKeys: missingActionKeys }, - }, - ); - } - } -} +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- /** - * Dispatch a tool call against a bound document handle. + * Dispatch a tool call against a bound document handle using the default + * preset (`legacy`). + * + * The document handle injects session targeting automatically; tool arguments + * should not contain `doc` or `sessionId`. * - * The document handle injects session targeting automatically. - * Tool arguments should not contain `doc` or `sessionId`. + * For preset-aware dispatch — e.g. when comparing two presets — call + * `getPreset('id').dispatch(...)` directly. */ export async function dispatchSuperDocTool( documentHandle: BoundDocApi, @@ -360,81 +131,32 @@ export async function dispatchSuperDocTool( args: Record = {}, invokeOptions?: InvokeOptions, ): Promise { - if (!isRecord(args)) { - throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { - code: 'INVALID_ARGUMENT', - details: { toolName }, - }); - } - - const sanitizedArgs = stripCorruptedToolArgKeys(args); - if (!isRecord(sanitizedArgs)) { - throw new SuperDocCliError(`Tool arguments for ${toolName} must be an object.`, { - code: 'INVALID_ARGUMENT', - details: { toolName }, - }); - } - - // Validate against the tool schema before dispatch. - const catalog = await getCachedCatalog(); - const tool = catalog.tools.find((t) => t.toolName === toolName); - if (tool == null) { - throw new SuperDocCliError(`Unknown tool: ${toolName}`, { - code: 'TOOL_DISPATCH_NOT_FOUND', - details: { toolName }, - }); - } - validateToolArgs(toolName, sanitizedArgs, tool); - - // Strip empty strings for known optional ID/enum params that LLMs fill with "" - // instead of omitting. Only target params where "" is never a valid value. - const cleanArgs: Record = {}; - for (const [key, value] of Object.entries(sanitizedArgs)) { - if (value === '' && STRIP_EMPTY_OPTIONAL_ARGS.has(key)) continue; - cleanArgs[key] = value; - } - - return dispatchIntentTool(toolName, cleanArgs, (operationId, input) => { - const method = resolveDocApiMethod(documentHandle, operationId); - return method(input, invokeOptions); - }); + return getPreset(DEFAULT_PRESET).dispatch(documentHandle, toolName, args, invokeOptions); } +// --------------------------------------------------------------------------- +// System prompts (preset-scoped; default to legacy) +// --------------------------------------------------------------------------- + /** - * Read the bundled SDK system prompt for intent tools. + * Read the packaged SDK system prompt (default preset: legacy). * - * This prompt includes a persona preamble ("You are a document editing assistant…") - * suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). - * For MCP server instructions, use {@link getMcpPrompt} instead. + * Includes a persona preamble ("You are a document editing assistant…") + * suitable for embedded LLM usage (OpenAI, Anthropic, Vercel APIs). For MCP + * server instructions, use {@link getMcpPrompt} instead. */ -export async function getSystemPrompt(): Promise { - const promptPath = path.join(toolsDir, 'system-prompt.md'); - try { - return await readFile(promptPath, 'utf8'); - } catch { - throw new SuperDocCliError('System prompt not found.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { filePath: promptPath }, - }); - } +export async function getSystemPrompt(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getSystemPrompt(); } /** - * Read the bundled MCP system prompt for intent tools. + * Read the packaged MCP system prompt for intent tools (default preset: legacy). * - * This prompt omits the persona preamble and includes session lifecycle - * instructions (open/save/close) suitable for MCP server `instructions`. + * Omits the persona preamble and includes session lifecycle instructions + * (open/save/close) suitable for MCP server `instructions`. */ -export async function getMcpPrompt(): Promise { - const promptPath = path.join(toolsDir, 'system-prompt-mcp.md'); - try { - return await readFile(promptPath, 'utf8'); - } catch { - throw new SuperDocCliError('MCP system prompt not found.', { - code: 'TOOLS_ASSET_NOT_FOUND', - details: { filePath: promptPath }, - }); - } +export async function getMcpPrompt(preset?: string): Promise { + return getPreset(preset ?? DEFAULT_PRESET).getMcpPrompt(); } // --------------------------------------------------------------------------- @@ -481,9 +203,10 @@ export type SystemPromptForProviderResult = */ export async function getSystemPromptForProvider(input: { provider: ToolProvider; + preset?: string; cache?: boolean; }): Promise { - const text = await getSystemPrompt(); + const text = await getSystemPrompt(input.preset); const cacheRequested = input.cache === true; if (input.provider === 'anthropic') { diff --git a/packages/sdk/langs/python/pyproject.toml b/packages/sdk/langs/python/pyproject.toml index f348eb8488..dfa530296b 100644 --- a/packages/sdk/langs/python/pyproject.toml +++ b/packages/sdk/langs/python/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ packages = [ "superdoc", "superdoc.generated", + "superdoc.presets", "superdoc.skills", "superdoc.tools", ] diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index fa0b628a52..40ddf0e273 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -1,3 +1,4 @@ +from .presets import DEFAULT_PRESET, get_preset, list_presets from .client import AsyncSuperDocClient, AsyncSuperDocDocument, SuperDocClient, SuperDocDocument from .errors import SuperDocError from .skill_api import get_skill, install_skill, list_skills @@ -29,4 +30,7 @@ "dispatch_superdoc_tool_async", "get_mcp_prompt", "get_system_prompt", + "DEFAULT_PRESET", + "get_preset", + "list_presets", ] diff --git a/packages/sdk/langs/python/superdoc/presets/__init__.py b/packages/sdk/langs/python/superdoc/presets/__init__.py new file mode 100644 index 0000000000..3cb8c6cc29 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/presets/__init__.py @@ -0,0 +1,102 @@ +"""Preset registry for SuperDoc LLM tools (Python). + +Mirrors the Node SDK preset registry (see ``packages/sdk/langs/node/src/presets.ts``). +A preset is a self-contained collection of LLM tools — provider catalogs +(openai / anthropic / vercel / generic), a system prompt, and a dispatcher. +Multiple presets can coexist; consumers select one at runtime via +``choose_tools({'preset': ...})``. + +v1 ships a single preset: ``'legacy'`` — a thin wrapper around today's +codegen-emitted intent tools. When callers omit ``preset``, ``legacy`` is used. + +Presets are NOT versioned. The preset id encodes the variant; a new shape +ships as a new id, not a new version of an existing one. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Dict, List, Literal, Optional, Protocol + +from ..errors import SuperDocError + +ToolProvider = Literal['openai', 'anthropic', 'vercel', 'generic'] + + +class PresetDescriptor(Protocol): + """Self-contained preset of LLM tools. + + Mirrors the Node SDK PresetDescriptor interface 1:1. Each preset owns + its tool catalogs per provider, its system prompts, and its dispatcher. + """ + + id: str + description: str + supports_cache_control: bool + + def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: ... + def get_catalog(self) -> Dict[str, Any]: ... + def get_system_prompt(self) -> str: ... + def get_mcp_prompt(self) -> str: ... + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Any: ... + def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Awaitable[Any]: ... + + +# Lazy import to avoid the registry pulling in heavy modules at package load. +def _build_registry() -> Dict[str, PresetDescriptor]: + from .legacy import legacy_preset # noqa: WPS433 — intentional lazy import + return {'legacy': legacy_preset} + + +DEFAULT_PRESET: str = 'legacy' +_PRESETS: Optional[Dict[str, PresetDescriptor]] = None + + +def _registry() -> Dict[str, PresetDescriptor]: + global _PRESETS + if _PRESETS is None: + _PRESETS = _build_registry() + return _PRESETS + + +def list_presets() -> List[str]: + """List the IDs of all registered presets.""" + return list(_registry().keys()) + + +def get_preset(preset_id: Optional[str] = None) -> PresetDescriptor: + """Resolve a preset by ID. + + Raises :class:`SuperDocError` with code ``PRESET_NOT_FOUND`` if the ID is + not registered. Omit the argument to get the default preset. + """ + resolved = preset_id if preset_id is not None else DEFAULT_PRESET + registry = _registry() + preset = registry.get(resolved) + if preset is None: + raise SuperDocError( + f'Unknown LLM-tools preset: "{resolved}"', + code='PRESET_NOT_FOUND', + details={'id': resolved, 'availablePresets': list(registry.keys())}, + ) + return preset + + +__all__ = [ + 'PresetDescriptor', + 'DEFAULT_PRESET', + 'ToolProvider', + 'get_preset', + 'list_presets', +] diff --git a/packages/sdk/langs/python/superdoc/presets/legacy.py b/packages/sdk/langs/python/superdoc/presets/legacy.py new file mode 100644 index 0000000000..e43f305bb9 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/presets/legacy.py @@ -0,0 +1,278 @@ +"""Legacy preset — wraps the existing codegen-emitted intent tools verbatim. + +Mirrors ``packages/sdk/langs/node/src/presets/legacy.ts``. The legacy preset is +a read-through over the packaged tool artifacts in ``superdoc/tools/`` (catalog, +per-provider tool JSON, system prompts) and delegates dispatch to the +codegen-emitted ``dispatch_intent_tool``. It is the default preset returned +by ``choose_tools()`` when callers omit ``preset``. + +Nothing in this file relocates or rewrites the packaged artifacts. The whole +point of the read-through wrapper is that running ``generate:all`` continues +to refresh the package assets in place; the legacy preset picks up the new +files on the next call. +""" + +from __future__ import annotations + +import inspect +import json +import re +from dataclasses import dataclass +from importlib import resources +from typing import Any, Awaitable, Dict, List, Optional, cast + +from ..errors import SuperDocError +from ..tools.intent_dispatch_generated import dispatch_intent_tool +from . import ToolProvider + +_PROVIDER_FILE: Dict[ToolProvider, str] = { + 'openai': 'tools.openai.json', + 'anthropic': 'tools.anthropic.json', + 'vercel': 'tools.vercel.json', + 'generic': 'tools.generic.json', +} + + +def _read_json_asset(name: str) -> Dict[str, Any]: + resource = resources.files('superdoc').joinpath('tools', name) + try: + raw = resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'Unable to load packaged tool artifact.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': name}, + ) from error + except Exception as error: + raise SuperDocError( + 'Unable to read packaged tool artifact.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': name, 'message': str(error)}, + ) from error + + try: + parsed = json.loads(raw) + except Exception as error: + raise SuperDocError( + 'Packaged tool artifact is invalid JSON.', + code='TOOLS_ASSET_INVALID', + details={'file': name, 'message': str(error)}, + ) from error + + if not isinstance(parsed, dict): + raise SuperDocError( + 'Packaged tool artifact root must be an object.', + code='TOOLS_ASSET_INVALID', + details={'file': name}, + ) + + return cast(Dict[str, Any], parsed) + + +_catalog_cache: Optional[Dict[str, Any]] = None + + +def _get_catalog_cached() -> Dict[str, Any]: + global _catalog_cache + if _catalog_cache is None: + _catalog_cache = _read_json_asset('catalog.json') + return _catalog_cache + + +def _apply_cache_markers( + tools: List[Any], + provider: ToolProvider, + cache_requested: bool, +) -> Dict[str, Any]: + if not cache_requested: + return {'tools': tools, 'cacheStrategy': 'disabled'} + + if provider == 'anthropic': + if not tools: + return {'tools': tools, 'cacheStrategy': 'explicit'} + # Mark the LAST tool with cache_control — caches the entire tools block. + next_tools = list(tools[:-1]) + last = dict(tools[-1]) if isinstance(tools[-1], dict) else tools[-1] + if isinstance(last, dict): + last['cache_control'] = {'type': 'ephemeral'} + next_tools.append(last) + return {'tools': next_tools, 'cacheStrategy': 'explicit'} + + if provider == 'openai': + return {'tools': tools, 'cacheStrategy': 'automatic'} + + return {'tools': tools, 'cacheStrategy': 'unsupported'} + + +def _snake_case(token: str) -> str: + token = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', token) + token = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', token) + return token.replace('-', '_').lower() + + +def _resolve_doc_method(document_handle: Any, operation_id: str) -> Any: + cursor = document_handle + for token in operation_id.split('.')[1:]: + candidates = [token] + snake_token = _snake_case(token) + if snake_token != token: + candidates.append(snake_token) + + resolved = None + for candidate in candidates: + if hasattr(cursor, candidate): + resolved = getattr(cursor, candidate) + break + + if resolved is None: + raise SuperDocError( + 'No SDK doc method found for operation.', + code='TOOL_DISPATCH_NOT_FOUND', + details={'operationId': operation_id, 'token': token}, + ) + cursor = resolved + + if not callable(cursor): + raise SuperDocError( + 'Resolved SDK doc member is not callable.', + code='TOOL_DISPATCH_NOT_FOUND', + details={'operationId': operation_id}, + ) + + return cursor + + +def _legacy_get_tools(provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): + raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + provider_file = _read_json_asset(_PROVIDER_FILE[provider]) + tools = provider_file.get('tools') + raw_tools = tools if isinstance(tools, list) else [] + return _apply_cache_markers(cast(List[Any], raw_tools), provider, cache) + + +def _legacy_get_catalog() -> Dict[str, Any]: + return _get_catalog_cached() + + +def _legacy_get_system_prompt() -> str: + resource = resources.files('superdoc').joinpath('tools', 'system-prompt.md') + try: + return resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'System prompt not found.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': 'system-prompt.md'}, + ) from error + + +def _legacy_get_mcp_prompt() -> str: + resource = resources.files('superdoc').joinpath('tools', 'system-prompt-mcp.md') + try: + return resource.read_text(encoding='utf-8') + except FileNotFoundError as error: + raise SuperDocError( + 'MCP system prompt not found.', + code='TOOLS_ASSET_NOT_FOUND', + details={'file': 'system-prompt-mcp.md'}, + ) from error + + +def _legacy_dispatch( + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, +) -> Any: + payload = args or {} + if not isinstance(payload, dict): + raise SuperDocError( + 'Tool arguments must be an object.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name}, + ) + + payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + + def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: + method = _resolve_doc_method(document_handle, operation_id) + if inspect.iscoroutinefunction(method): + raise SuperDocError( + 'legacy.dispatch cannot call async methods. Use dispatch_async.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name, 'operationId': operation_id}, + ) + kwargs = dict(invoke_options or {}) + return method(input_args, **kwargs) + + return dispatch_intent_tool(tool_name, payload, execute) + + +async def _legacy_dispatch_async( + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, +) -> Any: + payload = args or {} + if not isinstance(payload, dict): + raise SuperDocError( + 'Tool arguments must be an object.', + code='INVALID_ARGUMENT', + details={'toolName': tool_name}, + ) + + payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + + def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: + method = _resolve_doc_method(document_handle, operation_id) + kwargs = dict(invoke_options or {}) + return method(input_args, **kwargs) + + result = dispatch_intent_tool(tool_name, payload, execute) + if inspect.isawaitable(result): + return await result + return result + + +@dataclass(frozen=True) +class _LegacyPreset: + id: str = 'legacy' + description: str = ( + 'Codegen-emitted intent tools (default). Wraps superdoc/tools/ artifacts verbatim.' + ) + supports_cache_control: bool = True + + def get_tools(self, provider: ToolProvider, *, cache: bool = False) -> Dict[str, Any]: + return _legacy_get_tools(provider, cache=cache) + + def get_catalog(self) -> Dict[str, Any]: + return _legacy_get_catalog() + + def get_system_prompt(self) -> str: + return _legacy_get_system_prompt() + + def get_mcp_prompt(self) -> str: + return _legacy_get_mcp_prompt() + + def dispatch( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Any: + return _legacy_dispatch(document_handle, tool_name, args, invoke_options) + + def dispatch_async( + self, + document_handle: Any, + tool_name: str, + args: Optional[Dict[str, Any]] = None, + invoke_options: Optional[Dict[str, Any]] = None, + ) -> Awaitable[Any]: + return _legacy_dispatch_async(document_handle, tool_name, args, invoke_options) + + +legacy_preset: _LegacyPreset = _LegacyPreset() diff --git a/packages/sdk/langs/python/superdoc/test_parity_helper.py b/packages/sdk/langs/python/superdoc/test_parity_helper.py index 08fbd86f93..92f1560ddc 100644 --- a/packages/sdk/langs/python/superdoc/test_parity_helper.py +++ b/packages/sdk/langs/python/superdoc/test_parity_helper.py @@ -26,6 +26,13 @@ def main() -> None: result.pop('tools', None) print(json.dumps({'ok': True, 'result': result})) + elif action == 'listPresets': + from superdoc import DEFAULT_PRESET, list_presets + print(json.dumps({'ok': True, 'result': { + 'defaultPreset': DEFAULT_PRESET, + 'presets': list_presets(), + }})) + elif action == 'resolveIntentDispatch': from superdoc.tools.intent_dispatch_generated import dispatch_intent_tool tool_name = command['toolName'] diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index 12ed092873..abcf051fc5 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -1,174 +1,113 @@ +"""Public LLM-tools API (Python SDK). Thin layer over the preset registry. + +Every call here resolves a preset (defaulting to ``legacy`` for backwards +compat) and delegates to it. Mirrors ``packages/sdk/langs/node/src/tools.ts``. +""" + from __future__ import annotations -import inspect -import json -import re -from importlib import resources -from typing import Any, Dict, List, Literal, Optional, TypedDict, cast +from typing import Any, Dict, List, Optional, TypedDict, cast +from .presets import DEFAULT_PRESET, ToolProvider, get_preset, list_presets from .errors import SuperDocError -from .tools.intent_dispatch_generated import dispatch_intent_tool -ToolProvider = Literal['openai', 'anthropic', 'vercel', 'generic'] +__all__ = [ + 'DEFAULT_PRESET', + 'ToolChooserInput', + 'ToolProvider', + 'choose_tools', + 'dispatch_superdoc_tool', + 'dispatch_superdoc_tool_async', + 'get_preset', + 'get_mcp_prompt', + 'get_system_prompt', + 'get_tool_catalog', + 'list_presets', + 'list_tools', +] class ToolChooserInput(TypedDict, total=False): provider: ToolProvider + # Preset ID to load tools from. Defaults to DEFAULT_PRESET ('legacy') + # for backwards compatibility. Use list_presets() to discover presets. + preset: str + # When True, applies provider-specific prompt-cache markers (Anthropic + # ``cache_control: { type: "ephemeral" }`` on the last tool, etc). + cache: bool -PROVIDER_FILE: Dict[ToolProvider, str] = { - 'openai': 'tools.openai.json', - 'anthropic': 'tools.anthropic.json', - 'vercel': 'tools.vercel.json', - 'generic': 'tools.generic.json', -} - +def get_tool_catalog(preset: Optional[str] = None) -> Dict[str, Any]: + """Return the full tool catalog for a preset (default: legacy).""" + return get_preset(preset).get_catalog() -def _read_json_asset(name: str) -> Dict[str, Any]: - resource = resources.files('superdoc').joinpath('tools', name) - try: - raw = resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'Unable to load packaged tool artifact.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': name}, - ) from error - except Exception as error: - raise SuperDocError( - 'Unable to read packaged tool artifact.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': name, 'message': str(error)}, - ) from error - - try: - parsed = json.loads(raw) - except Exception as error: - raise SuperDocError( - 'Packaged tool artifact is invalid JSON.', - code='TOOLS_ASSET_INVALID', - details={'file': name, 'message': str(error)}, - ) from error - - if not isinstance(parsed, dict): - raise SuperDocError( - 'Packaged tool artifact root must be an object.', - code='TOOLS_ASSET_INVALID', - details={'file': name}, - ) - return cast(Dict[str, Any], parsed) +def list_tools(provider: ToolProvider, preset: Optional[str] = None) -> List[Dict[str, Any]]: + """Return the raw tool array for a provider from a preset (default: legacy). - -def get_tool_catalog() -> Dict[str, Any]: - return _read_json_asset('catalog.json') - - -def list_tools(provider: ToolProvider) -> List[Dict[str, Any]]: - bundle = _read_json_asset(PROVIDER_FILE[provider]) - tools = bundle.get('tools') - if not isinstance(tools, list): + No cache markers applied. Use :func:`choose_tools` for cache markers and metadata. + """ + if provider not in ('openai', 'anthropic', 'vercel', 'generic'): raise SuperDocError( - 'Tool provider bundle is missing tools array.', - code='TOOLS_ASSET_INVALID', + 'provider is required.', + code='INVALID_ARGUMENT', details={'provider': provider}, ) + result = get_preset(preset).get_tools(provider, cache=False) + tools = result.get('tools') if isinstance(result.get('tools'), list) else [] return cast(List[Dict[str, Any]], tools) def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: - """Select all intent tools for a specific provider. - - Returns all intent tools in the requested provider format. + """Select tools for a specific provider from a preset. Example:: + # Default — legacy preset. result = choose_tools({'provider': 'openai'}) + + # Pick a specific preset. + result = choose_tools({'provider': 'anthropic', 'preset': 'legacy', 'cache': True}) """ provider = input.get('provider') if provider not in ('openai', 'anthropic', 'vercel', 'generic'): - raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) + raise SuperDocError( + 'provider is required.', + code='INVALID_ARGUMENT', + details={'provider': provider}, + ) - bundle = _read_json_asset(PROVIDER_FILE[provider]) - tools = bundle.get('tools') if isinstance(bundle.get('tools'), list) else [] + preset_id = input.get('preset') or DEFAULT_PRESET + cache_requested = bool(input.get('cache')) + + preset = get_preset(preset_id) + result = preset.get_tools(cast(ToolProvider, provider), cache=cache_requested) + tools = result.get('tools') if isinstance(result.get('tools'), list) else [] + cache_strategy = result.get('cacheStrategy', 'disabled') return { 'tools': tools, 'meta': { 'provider': provider, - 'toolCount': len(tools), + 'preset': preset_id, + 'toolCount': len(tools) if isinstance(tools, list) else 0, + 'cacheStrategy': cache_strategy, }, } -def _resolve_doc_method(document_handle: Any, operation_id: str) -> Any: - def _snake_case(token: str) -> str: - token = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', token) - token = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', token) - return token.replace('-', '_').lower() - - cursor = document_handle - for token in operation_id.split('.')[1:]: - candidates = [token] - snake_token = _snake_case(token) - if snake_token != token: - candidates.append(snake_token) - - resolved = None - for candidate in candidates: - if hasattr(cursor, candidate): - resolved = getattr(cursor, candidate) - break - - if resolved is None: - raise SuperDocError( - 'No SDK doc method found for operation.', - code='TOOL_DISPATCH_NOT_FOUND', - details={'operationId': operation_id, 'token': token}, - ) - cursor = resolved - - if not callable(cursor): - raise SuperDocError( - 'Resolved SDK doc member is not callable.', - code='TOOL_DISPATCH_NOT_FOUND', - details={'operationId': operation_id}, - ) - - return cursor - - def dispatch_superdoc_tool( document_handle: Any, tool_name: str, args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: - """Dispatch a tool call against a bound document handle. + """Dispatch a tool call against a bound document handle using the default preset. - The document handle injects session targeting automatically. - Tool arguments should not contain doc or sessionId — those are - stripped if present for backwards compatibility with older tool schemas. + The handle injects session targeting automatically; arguments should not + contain ``doc`` or ``sessionId`` — those are stripped if present. """ - payload = args or {} - if not isinstance(payload, dict): - raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - - # Strip doc/sessionId if present — the document handle manages targeting. - payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} - - def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(document_handle, operation_id) - if inspect.iscoroutinefunction(method): - raise SuperDocError( - 'dispatch_superdoc_tool cannot call async methods. Use dispatch_superdoc_tool_async.', - code='INVALID_ARGUMENT', - details={'toolName': tool_name, 'operationId': operation_id}, - ) - kwargs = dict(invoke_options or {}) - return method(input_args, **kwargs) - - return dispatch_intent_tool(tool_name, payload, execute) + return get_preset(DEFAULT_PRESET).dispatch(document_handle, tool_name, args, invoke_options) async def dispatch_superdoc_tool_async( @@ -177,55 +116,25 @@ async def dispatch_superdoc_tool_async( args: Optional[Dict[str, Any]] = None, invoke_options: Optional[Dict[str, Any]] = None, ) -> Any: - """Async version of dispatch_superdoc_tool. Dispatches against a bound document handle.""" - payload = args or {} - if not isinstance(payload, dict): - raise SuperDocError('Tool arguments must be an object.', code='INVALID_ARGUMENT', details={'toolName': tool_name}) - - # Strip doc/sessionId if present — the document handle manages targeting. - payload = {k: v for k, v in payload.items() if k not in ('doc', 'sessionId')} + """Async version of :func:`dispatch_superdoc_tool`.""" + return await get_preset(DEFAULT_PRESET).dispatch_async( + document_handle, tool_name, args, invoke_options, + ) - def execute(operation_id: str, input_args: Dict[str, Any]) -> Any: - method = _resolve_doc_method(document_handle, operation_id) - kwargs = dict(invoke_options or {}) - return method(input_args, **kwargs) - result = dispatch_intent_tool(tool_name, payload, execute) - if inspect.isawaitable(result): - return await result - return result +def get_system_prompt(preset: Optional[str] = None) -> str: + """Read the packaged SDK system prompt (default preset: legacy). - -def get_system_prompt() -> str: - """Read the bundled SDK system prompt for intent tools. - - This prompt includes a persona preamble suitable for embedded LLM usage - (OpenAI, Anthropic APIs). For MCP server instructions, use - :func:`get_mcp_prompt` instead. + Includes a persona preamble suitable for embedded LLM usage. For MCP + server instructions, use :func:`get_mcp_prompt` instead. """ - resource = resources.files('superdoc').joinpath('tools', 'system-prompt.md') - try: - return resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'System prompt not found.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': 'system-prompt.md'}, - ) from error + return get_preset(preset).get_system_prompt() -def get_mcp_prompt() -> str: - """Read the bundled MCP system prompt for intent tools. +def get_mcp_prompt(preset: Optional[str] = None) -> str: + """Read the packaged MCP system prompt for intent tools (default preset: legacy). - This prompt omits the persona preamble and includes session lifecycle - instructions (open/save/close) suitable for MCP server ``instructions``. + Omits the persona preamble and includes session lifecycle instructions + (open/save/close) suitable for MCP server ``instructions``. """ - resource = resources.files('superdoc').joinpath('tools', 'system-prompt-mcp.md') - try: - return resource.read_text(encoding='utf-8') - except FileNotFoundError as error: - raise SuperDocError( - 'MCP system prompt not found.', - code='TOOLS_ASSET_NOT_FOUND', - details={'file': 'system-prompt-mcp.md'}, - ) from error + return get_preset(preset).get_mcp_prompt() diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py new file mode 100644 index 0000000000..2a64add2d6 --- /dev/null +++ b/packages/sdk/langs/python/tests/test_presets.py @@ -0,0 +1,137 @@ +"""Preset registry tests (Python SDK) — mirrors Node SDK presets.test.ts.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from superdoc import ( # noqa: E402 + DEFAULT_PRESET, + SuperDocError, + choose_tools, + get_preset, + get_mcp_prompt, + get_system_prompt, + get_tool_catalog, + list_presets, + list_tools, +) + + +PROVIDERS = ('openai', 'anthropic', 'vercel', 'generic') + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +def test_default_preset_is_legacy(): + assert DEFAULT_PRESET == 'legacy' + + +def test_list_presets_includes_legacy(): + presets = list_presets() + assert 'legacy' in presets + + +def test_get_preset_no_arg_returns_legacy(): + preset = get_preset() + assert preset.id == 'legacy' + + +def test_get_preset_explicit_returns_legacy(): + preset = get_preset('legacy') + assert preset.id == 'legacy' + assert preset.description + assert preset.supports_cache_control is True + + +def test_get_preset_nonexistent_raises_preset_not_found(): + with pytest.raises(SuperDocError) as excinfo: + get_preset('nonexistent-preset') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + assert 'nonexistent-preset' in str(excinfo.value) + assert excinfo.value.details['id'] == 'nonexistent-preset' + assert 'legacy' in excinfo.value.details['availablePresets'] + + +# --------------------------------------------------------------------------- +# choose_tools — default preset equivalence +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_choose_tools_omit_preset_equals_legacy(provider): + implicit = choose_tools({'provider': provider}) + explicit = choose_tools({'provider': provider, 'preset': 'legacy'}) + assert implicit['tools'] == explicit['tools'] + assert implicit['meta']['toolCount'] == explicit['meta']['toolCount'] + assert implicit['meta']['provider'] == explicit['meta']['provider'] + assert implicit['meta']['cacheStrategy'] == explicit['meta']['cacheStrategy'] + assert implicit['meta']['preset'] == 'legacy' + assert explicit['meta']['preset'] == 'legacy' + + +def test_choose_tools_nonexistent_preset_raises(): + with pytest.raises(SuperDocError) as excinfo: + choose_tools({'provider': 'openai', 'preset': 'nonexistent-preset'}) + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +def test_choose_tools_meta_preset_field_present(): + result = choose_tools({'provider': 'openai'}) + assert result['meta']['preset'] == 'legacy' + + +# --------------------------------------------------------------------------- +# Catalog + listings — default preset equivalence +# --------------------------------------------------------------------------- + +def test_get_tool_catalog_default_equals_legacy(): + implicit = get_tool_catalog() + explicit = get_tool_catalog('legacy') + assert implicit == explicit + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_list_tools_default_equals_legacy(provider): + implicit = list_tools(provider) + explicit = list_tools(provider, 'legacy') + assert implicit == explicit + + +def test_get_tool_catalog_nonexistent_preset_raises(): + with pytest.raises(SuperDocError) as excinfo: + get_tool_catalog('nonexistent-preset') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +# --------------------------------------------------------------------------- +# System prompts — default preset equivalence +# --------------------------------------------------------------------------- + +def test_get_system_prompt_default_equals_legacy(): + assert get_system_prompt() == get_system_prompt('legacy') + + +def test_get_mcp_prompt_default_equals_legacy(): + assert get_mcp_prompt() == get_mcp_prompt('legacy') + + +# --------------------------------------------------------------------------- +# Direct preset access +# --------------------------------------------------------------------------- + +def test_preset_get_catalog_matches_top_level(): + direct = get_preset('legacy').get_catalog() + via_top_level = get_tool_catalog() + assert direct == via_top_level + + +@pytest.mark.parametrize('provider', PROVIDERS) +def test_preset_get_tools_matches_choose_tools(provider): + direct = get_preset('legacy').get_tools(provider) + via_top_level = choose_tools({'provider': provider}) + assert direct['tools'] == via_top_level['tools'] + assert direct['cacheStrategy'] == via_top_level['meta']['cacheStrategy'] From bfd052571bee8f5b063687539be9720acce2802c Mon Sep 17 00:00:00 2001 From: aorlov Date: Fri, 29 May 2026 23:41:05 +0200 Subject: [PATCH 2/2] fix(sdk): address PR review for preset registry (SD-3128) Three review findings from PR 3541: 1. Restore structured ToolCatalog.tools type. The refactor narrowed the public catalog row to `unknown[]`, breaking TS consumers that read tools[i].toolName etc. Move ToolCatalogEntry + ToolCatalogOperation into presets.ts as public types and tighten the catalog signature. 2. Fail fast on malformed provider bundles. Node and Python preset loaders previously coerced a missing or non-array `tools` field to `[]`, hiding broken codegen output behind a silently empty tool surface. Restore the pre-presets TOOLS_ASSET_INVALID throw at the preset boundary. 3. Cross-lang parity for empty-string presets. Python choose_tools treated `{'preset': ''}` as legacy via `or DEFAULT_PRESET`; Node and MCP both raise PRESET_NOT_FOUND. Use an explicit None check so Python matches. Tests added covering structural catalog access, empty-string preset fail-fast, and cross-lang parity for the empty-string case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../langs/node/src/__tests__/presets.test.ts | 33 ++++++++++++ packages/sdk/langs/node/src/index.ts | 2 + packages/sdk/langs/node/src/presets.ts | 29 ++++++++++- packages/sdk/langs/node/src/presets/legacy.ts | 52 +++++++++---------- packages/sdk/langs/node/src/tools.ts | 4 +- .../langs/python/superdoc/presets/legacy.py | 12 ++++- .../sdk/langs/python/superdoc/tools_api.py | 7 ++- .../sdk/langs/python/tests/test_presets.py | 15 ++++++ 8 files changed, 122 insertions(+), 32 deletions(-) diff --git a/packages/sdk/langs/node/src/__tests__/presets.test.ts b/packages/sdk/langs/node/src/__tests__/presets.test.ts index b789e14738..28f17a1be3 100644 --- a/packages/sdk/langs/node/src/__tests__/presets.test.ts +++ b/packages/sdk/langs/node/src/__tests__/presets.test.ts @@ -50,6 +50,39 @@ describe('preset registry', () => { expect(details.availablePresets).toContain('legacy'); } }); + + test('getPreset("") throws PRESET_NOT_FOUND (empty string is not the default)', () => { + try { + getPreset(''); + throw new Error('Expected getPreset("") to throw.'); + } catch (error) { + expect(error).toBeInstanceOf(SuperDocCliError); + expect((error as SuperDocCliError).code).toBe('PRESET_NOT_FOUND'); + } + }); + + test('chooseTools({preset: ""}) throws PRESET_NOT_FOUND (cross-lang parity)', async () => { + await expect(chooseTools({ provider: 'openai', preset: '' })).rejects.toMatchObject({ + code: 'PRESET_NOT_FOUND', + }); + }); +}); + +describe('public ToolCatalog type — structural access', () => { + test('getToolCatalog().tools entries expose typed properties', async () => { + const catalog = await getToolCatalog(); + expect(catalog.tools.length).toBeGreaterThan(0); + const first = catalog.tools[0]!; + // These property accesses validate that ToolCatalog.tools is structurally + // typed (ToolCatalogEntry[]) — not unknown[]. Compile failure here means + // the public catalog row type regressed. + expect(typeof first.toolName).toBe('string'); + expect(typeof first.description).toBe('string'); + expect(typeof first.mutates).toBe('boolean'); + expect(Array.isArray(first.operations)).toBe(true); + expect(typeof first.operations[0]?.operationId).toBe('string'); + expect(typeof first.operations[0]?.intentAction).toBe('string'); + }); }); describe('chooseTools — default preset equivalence', () => { diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index 68b33aee19..86ba0123b9 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -262,6 +262,8 @@ export type { CacheStrategy, SystemPromptForProviderResult, ToolCatalog, + ToolCatalogEntry, + ToolCatalogOperation, ToolChooserInput, ToolProvider, } from './tools.js'; diff --git a/packages/sdk/langs/node/src/presets.ts b/packages/sdk/langs/node/src/presets.ts index b3633dbfb3..460dea6e88 100644 --- a/packages/sdk/langs/node/src/presets.ts +++ b/packages/sdk/langs/node/src/presets.ts @@ -44,6 +44,33 @@ export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic'; */ export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled'; +/** + * One operation row in a {@link ToolCatalogEntry}. Each catalog entry can + * dispatch to one or more operations (e.g. multi-action intent tools), so + * the catalog records the operation id and the action discriminator that + * routes to it. + */ +export type ToolCatalogOperation = { + operationId: string; + intentAction: string; + required?: string[]; + requiredOneOf?: string[][]; +}; + +/** + * One entry in the {@link ToolCatalog}. Matches the shape of the catalog + * emitted by the legacy preset's codegen — kept stable as the public + * catalog row shape so TypeScript consumers can introspect `tools[i]` + * without losing property typing. + */ +export type ToolCatalogEntry = { + toolName: string; + description: string; + inputSchema: Record; + mutates: boolean; + operations: ToolCatalogOperation[]; +}; + /** * Full tool catalog shape. The legacy preset returns the existing codegen * catalog with `contractVersion`, `generatedAt`, `toolCount`, `tools`. @@ -52,7 +79,7 @@ export type ToolCatalog = { contractVersion: string; generatedAt: string | null; toolCount: number; - tools: unknown[]; + tools: ToolCatalogEntry[]; }; export interface GetToolsOptions { diff --git a/packages/sdk/langs/node/src/presets/legacy.ts b/packages/sdk/langs/node/src/presets/legacy.ts index a8336fa755..7351ee893b 100644 --- a/packages/sdk/langs/node/src/presets/legacy.ts +++ b/packages/sdk/langs/node/src/presets/legacy.ts @@ -21,7 +21,15 @@ import type { BoundDocApi } from '../generated/client.js'; import type { InvokeOptions } from '../runtime/process.js'; import { SuperDocCliError } from '../runtime/errors.js'; import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js'; -import type { PresetDescriptor, GetToolsOptions, GetToolsResult, ToolCatalog, ToolProvider } from '../presets.js'; +import type { + GetToolsOptions, + GetToolsResult, + PresetDescriptor, + ToolCatalog, + ToolCatalogEntry, + ToolCatalogOperation, + ToolProvider, +} from '../presets.js'; // Resolve tools directory relative to package root (works from both src/ and dist/) const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools'); @@ -33,23 +41,6 @@ const providerFileByName: Record = { generic: 'tools.generic.json', }; -type OperationEntry = { - operationId: string; - intentAction: string; - required?: string[]; - requiredOneOf?: string[][]; -}; - -type ToolCatalogEntry = { - toolName: string; - description: string; - inputSchema: Record; - mutates: boolean; - operations: OperationEntry[]; -}; - -type LegacyToolCatalog = ToolCatalog & { tools: ToolCatalogEntry[] }; - const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']); function isRecord(value: unknown): value is Record { @@ -103,11 +94,11 @@ async function readProviderTools(provider: ToolProvider): Promise<{ } // Cached catalog instance — loaded once per process. -let _catalogCache: LegacyToolCatalog | null = null; +let _catalogCache: ToolCatalog | null = null; -async function getCachedCatalog(): Promise { +async function getCachedCatalog(): Promise { if (_catalogCache == null) { - _catalogCache = await readJson('catalog.json'); + _catalogCache = await readJson('catalog.json'); } return _catalogCache; } @@ -210,7 +201,7 @@ function validateToolArgs(toolName: string, args: Record, tool: // 3. Reject missing per-operation required keys. For multi-action tools, // resolve the operation by action; for single-op tools, use the sole entry. const action = args.action; - let op: OperationEntry | undefined; + let op: ToolCatalogOperation | undefined; if (typeof action === 'string' && tool.operations.length > 1) { op = tool.operations.find((o) => o.intentAction === action); } else if (tool.operations.length === 1) { @@ -230,7 +221,7 @@ function validateOperationRequired( toolName: string, action: unknown, args: Record, - op: OperationEntry, + op: ToolCatalogOperation, ): void { const actionLabel = typeof action === 'string' ? ` action "${action}"` : ''; @@ -262,8 +253,16 @@ function validateOperationRequired( async function legacyGetTools(provider: ToolProvider, options?: GetToolsOptions): Promise { const { tools } = await readProviderTools(provider); - const rawTools = Array.isArray(tools) ? tools : []; - return applyCacheMarkers(rawTools, provider, options?.cache === true); + // Fail fast on malformed provider artifacts so agents don't silently boot + // with zero tools. Matches the pre-presets behavior of the public + // `listTools` path (TOOLS_ASSET_INVALID). + if (!Array.isArray(tools)) { + throw new SuperDocCliError('Tool provider bundle is missing tools array.', { + code: 'TOOLS_ASSET_INVALID', + details: { provider }, + }); + } + return applyCacheMarkers(tools, provider, options?.cache === true); } async function legacyGetCatalog(): Promise { @@ -316,8 +315,7 @@ async function legacyDispatch( } const catalog = await getCachedCatalog(); - const entries = catalog.tools as ToolCatalogEntry[]; - const tool = entries.find((t) => t.toolName === toolName); + const tool = catalog.tools.find((t) => t.toolName === toolName); if (tool == null) { throw new SuperDocCliError(`Unknown tool: ${toolName}`, { code: 'TOOL_DISPATCH_NOT_FOUND', diff --git a/packages/sdk/langs/node/src/tools.ts b/packages/sdk/langs/node/src/tools.ts index 0c8240759e..68fea3a78f 100644 --- a/packages/sdk/langs/node/src/tools.ts +++ b/packages/sdk/langs/node/src/tools.ts @@ -16,11 +16,13 @@ import { listPresets, type CacheStrategy, type ToolCatalog, + type ToolCatalogEntry, + type ToolCatalogOperation, type ToolProvider, } from './presets.js'; export { DEFAULT_PRESET, getPreset, listPresets }; -export type { CacheStrategy, ToolCatalog, ToolProvider }; +export type { CacheStrategy, ToolCatalog, ToolCatalogEntry, ToolCatalogOperation, ToolProvider }; // --------------------------------------------------------------------------- // chooseTools — provider-shaped tool list with optional cache markers diff --git a/packages/sdk/langs/python/superdoc/presets/legacy.py b/packages/sdk/langs/python/superdoc/presets/legacy.py index e43f305bb9..33647150eb 100644 --- a/packages/sdk/langs/python/superdoc/presets/legacy.py +++ b/packages/sdk/langs/python/superdoc/presets/legacy.py @@ -147,8 +147,16 @@ def _legacy_get_tools(provider: ToolProvider, *, cache: bool = False) -> Dict[st raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider}) provider_file = _read_json_asset(_PROVIDER_FILE[provider]) tools = provider_file.get('tools') - raw_tools = tools if isinstance(tools, list) else [] - return _apply_cache_markers(cast(List[Any], raw_tools), provider, cache) + # Fail fast on malformed provider artifacts so agents don't silently boot + # with zero tools. Matches the Node legacy preset's behavior and the + # pre-presets contract of the public list_tools path. + if not isinstance(tools, list): + raise SuperDocError( + 'Tool provider bundle is missing tools array.', + code='TOOLS_ASSET_INVALID', + details={'provider': provider}, + ) + return _apply_cache_markers(cast(List[Any], tools), provider, cache) def _legacy_get_catalog() -> Dict[str, Any]: diff --git a/packages/sdk/langs/python/superdoc/tools_api.py b/packages/sdk/langs/python/superdoc/tools_api.py index abcf051fc5..87847c16a2 100644 --- a/packages/sdk/langs/python/superdoc/tools_api.py +++ b/packages/sdk/langs/python/superdoc/tools_api.py @@ -77,7 +77,12 @@ def choose_tools(input: ToolChooserInput) -> Dict[str, Any]: details={'provider': provider}, ) - preset_id = input.get('preset') or DEFAULT_PRESET + # Default only when `preset` is absent. An explicit empty string is passed + # through to get_preset() so it raises PRESET_NOT_FOUND, matching Node/MCP + # fail-fast behavior. Using `or DEFAULT_PRESET` would silently treat + # `preset: ''` as legacy and hide misconfiguration. + preset_arg = input.get('preset') + preset_id = preset_arg if preset_arg is not None else DEFAULT_PRESET cache_requested = bool(input.get('cache')) preset = get_preset(preset_id) diff --git a/packages/sdk/langs/python/tests/test_presets.py b/packages/sdk/langs/python/tests/test_presets.py index 2a64add2d6..7178496b37 100644 --- a/packages/sdk/langs/python/tests/test_presets.py +++ b/packages/sdk/langs/python/tests/test_presets.py @@ -57,6 +57,21 @@ def test_get_preset_nonexistent_raises_preset_not_found(): assert 'legacy' in excinfo.value.details['availablePresets'] +def test_get_preset_empty_string_raises_preset_not_found(): + """Empty string is NOT the default — it must fail fast like Node.""" + with pytest.raises(SuperDocError) as excinfo: + get_preset('') + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + +def test_choose_tools_empty_preset_raises_preset_not_found(): + """Cross-lang parity with Node: chooseTools({preset: ''}) must throw, not + silently use legacy.""" + with pytest.raises(SuperDocError) as excinfo: + choose_tools({'provider': 'openai', 'preset': ''}) + assert excinfo.value.code == 'PRESET_NOT_FOUND' + + # --------------------------------------------------------------------------- # choose_tools — default preset equivalence # ---------------------------------------------------------------------------