diff --git a/docs/configuration.md b/docs/configuration.md index 3f884459..39d01bdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -390,6 +390,13 @@ variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix lists the supported ones and suggests the closest, and a missing optional peer dependency prints the exact `npm install` command. +An option `createAgent()` does not know is an error (`LOUSHO_CONFIG_INVALID`), +not silently ignored, so a typo fails from JavaScript or YAML too. +The message suggests the closest option, or the Lousho name for one other SDKs +use: `'maxOutputTokens' is not a known option (did you mean 'modelSettings.maxTokens'?)`, +and `system` points to `instructions`. A key set to `undefined` is ignored, and +the `manifest` of a spread `claudeProject()` result is accepted. + ## Model settings `modelSettings` sets the sampling options of every model call an agent makes. diff --git a/docs/tools.md b/docs/tools.md index bec926dc..c0296cd6 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -52,6 +52,12 @@ Tools written for the Vercel AI SDK (`tool({ description, inputSchema, execute } called, and throws an error that says how to fix a bad value. Registering two tools with the same name throws an error naming the conflict. +An option not in the table above is an error (`LOUSHO_CONFIG_INVALID`) with a +suggestion, so a misspelled safety option cannot leave a tool running without +approval: `requireApproval: true` gives `'requireApproval' is not a known option (did you mean 'needsApproval'?)`, +and `inputSchema` or `parameters` points to `input`. A defined tool spread back +in (`defineTool({ ...tool, execute })`) is accepted. + It also refuses (`LOUSHO_CONFIG_INVALID`) an `input` no model can satisfy: - **The root must be an object.** Providers send tool arguments as a JSON diff --git a/llms-full.txt b/llms-full.txt index 3b98e71f..79fccb74 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -6651,6 +6651,13 @@ variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix lists the supported ones and suggests the closest, and a missing optional peer dependency prints the exact `npm install` command. +An option `createAgent()` does not know is an error (`LOUSHO_CONFIG_INVALID`), +not silently ignored, so a typo fails from JavaScript or YAML too. +The message suggests the closest option, or the Lousho name for one other SDKs +use: `'maxOutputTokens' is not a known option (did you mean 'modelSettings.maxTokens'?)`, +and `system` points to `instructions`. A key set to `undefined` is ignored, and +the `manifest` of a spread `claudeProject()` result is accepted. + ## Model settings `modelSettings` sets the sampling options of every model call an agent makes. @@ -19281,6 +19288,12 @@ Tools written for the Vercel AI SDK (`tool({ description, inputSchema, execute } called, and throws an error that says how to fix a bad value. Registering two tools with the same name throws an error naming the conflict. +An option not in the table above is an error (`LOUSHO_CONFIG_INVALID`) with a +suggestion, so a misspelled safety option cannot leave a tool running without +approval: `requireApproval: true` gives `'requireApproval' is not a known option (did you mean 'needsApproval'?)`, +and `inputSchema` or `parameters` points to `input`. A defined tool spread back +in (`defineTool({ ...tool, execute })`) is accepted. + It also refuses (`LOUSHO_CONFIG_INVALID`) an `input` no model can satisfy: - **The root must be an object.** Providers send tool arguments as a JSON diff --git a/src/createAgent.test.ts b/src/createAgent.test.ts index dcff0a51..7a6aaaba 100644 --- a/src/createAgent.test.ts +++ b/src/createAgent.test.ts @@ -135,6 +135,31 @@ describe('createAgent', () => { }); }); + describe('unknown options', () => { + const provider = createMockProvider(); + it.each([ + ['maxOutputTokens', 'modelSettings.maxTokens'], + ['max_tokens', 'modelSettings.maxTokens'], + ['temperature', 'modelSettings.temperature'], + ['system', 'instructions'], + ['systemPrompt', 'instructions'], + ['maxStep', 'maxSteps'], + ['permisions', 'permissions'], + ])("rejects '%s' and suggests '%s'", (key, suggestion) => { + expect(() => createAgent({ provider, [key]: 100 } as never)).toThrow( + expect.objectContaining({ code: 'LOUSHO_CONFIG_INVALID', message: expect.stringContaining(`createAgent: '${key}' is not a known option (did you mean '${suggestion}'?)`) }) + ); + }); + + it('rejects an unknown option with no plausible match, listing the allowed ones', () => { + expect(() => createAgent({ provider, frobnicate: true } as never)).toThrow(/'frobnicate' is not a known option\. Allowed options: tools, /); + }); + + it('ignores an unknown option whose value is undefined', () => { + expect(() => createAgent({ provider, system: undefined } as never)).not.toThrow(); + }); + }); + describe('error messages', () => { it('names the missing API key and the provider escape hatch', () => { expect(() => createAgent({ model: 'openai/gpt-4o-mini' })).toThrow( diff --git a/src/createAgent.ts b/src/createAgent.ts index 82abbf33..99197d75 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -21,6 +21,7 @@ import { streamResumed, throwingRun, type AgentRun } from './execution/agentRun' import type { AgentEvent } from './execution/agentEvents'; import type { TraceExporter } from './execution/tracing'; import { assertModelSettings, mergeModelSettings } from './execution/modelSettings'; +import { assertKnownCreateAgentKeys } from './createAgentKeys'; import { DEFAULT_AGENT_NAME } from './execution/genAiSpans'; import { LLMProvider, LLMProviderRegistry } from './providers/llm'; import { ToolRegistry } from './tools/ToolRegistry'; @@ -942,6 +943,7 @@ export function createAgentConfigOf(agent: SimpleAgent): CreateAgentConfig | und export function createAgent( config: CreateAgentConfig = {} ): SimpleAgent> { + assertKnownCreateAgentKeys(config); assertToolConcurrency(config.toolConcurrency, 'createAgent'); assertMaxSteps(config.maxSteps, 'createAgent'); assertModelSettings(config.modelSettings, 'createAgent'); diff --git a/src/createAgentKeys.ts b/src/createAgentKeys.ts new file mode 100644 index 00000000..5278128e --- /dev/null +++ b/src/createAgentKeys.ts @@ -0,0 +1,84 @@ +/** + * The option keys createAgent() accepts. An unknown key (`maxOutputTokens`, + * `system`, a typo) is rejected with a "did you mean" instead of being + * silently ignored, which TypeScript only catches for object literals. + */ +import type { ClaudeProject } from './claude/claudeProject'; +import type { CreateAgentConfig } from './createAgent'; +import { ConfigurationError } from './execution/errors'; +import { MODEL_SETTING_KEYS, SETTING_SYNONYMS } from './execution/modelSettings'; +import { unknownOptionKey } from './utils/knownOptionKeys'; + +/** Every {@link CreateAgentConfig} key; `satisfies` keeps it in step with the type. */ +const CREATE_AGENT_KEYS = { + tools: true, + mcpServers: true, + skills: true, + name: true, + description: true, + subagents: true, + subagentOptions: true, + handoffs: true, + maxHandoffs: true, + toolSearch: true, + codeMode: true, + maxSubagentDepth: true, + maxSteps: true, + limits: true, + guardrails: true, + toolConcurrency: true, + maxToolResultChars: true, + onAgentDrift: true, + reasoning: true, + modelSettings: true, + promptCaching: true, + onEvent: true, + exporter: true, + captureContent: true, + redactContent: true, + projectInstructions: true, + store: true, + approvalStore: true, + approve: true, + approvalTtlMs: true, + askQuestion: true, + retry: true, + rateLimit: true, + fallbackModels: true, + output: true, + hooks: true, + compaction: true, + memory: true, + permissions: true, + onPermissionDecision: true, + permissionMode: true, + onPermissionModeChange: true, + instructions: true, + prompt: true, + model: true, + provider: true, +} as const satisfies Record; + +/** Keys SDK helpers return beside their createAgent() options, so their result spreads in: `createAgent({ provider, ...project })`. */ +const SPREAD_HELPER_KEYS = { + manifest: true, +} as const satisfies Record, true>; + +/** Names other agent SDKs use at the top level, mapped to ours (sampling settings live in `modelSettings`). */ +const CREATE_AGENT_ALIASES: Readonly> = { + system: 'instructions', + systemPrompt: 'instructions', + system_prompt: 'instructions', + stopWhen: 'maxSteps', + maxIterations: 'maxSteps', + maxTurns: 'maxSteps', + ...Object.fromEntries(MODEL_SETTING_KEYS.map((key) => [key, `modelSettings.${key}`])), + ...Object.fromEntries(Object.entries(SETTING_SYNONYMS).map(([alias, key]) => [alias, `modelSettings.${key}`])), +}; + +/** Throws `LOUSHO_CONFIG_INVALID` for a createAgent() option key it does not know, naming the key and a likely fix. */ +export function assertKnownCreateAgentKeys(config: CreateAgentConfig): void { + const unknown = unknownOptionKey(config, { ...CREATE_AGENT_KEYS, ...SPREAD_HELPER_KEYS }, CREATE_AGENT_ALIASES); + if (!unknown) return; + throw new ConfigurationError(`createAgent: ${unknown.problem}. Allowed options: ${Object.keys(CREATE_AGENT_KEYS).join(', ')}.`, unknown.key); +} diff --git a/src/execution/modelSettings.ts b/src/execution/modelSettings.ts index b3d97534..381180ec 100644 --- a/src/execution/modelSettings.ts +++ b/src/execution/modelSettings.ts @@ -39,7 +39,7 @@ const SETTING_RANGES: Readonly }; /** AI SDK (and provider API) names people reach for, mapped to ours. */ -const SETTING_SYNONYMS: Readonly> = { +export const SETTING_SYNONYMS: Readonly> = { maxOutputTokens: 'maxTokens', max_tokens: 'maxTokens', max_completion_tokens: 'maxTokens', diff --git a/src/tools/defineTool.test.ts b/src/tools/defineTool.test.ts index d65492e4..784f9a30 100644 --- a/src/tools/defineTool.test.ts +++ b/src/tools/defineTool.test.ts @@ -92,6 +92,34 @@ describe('defineTool', () => { expect(message).toContain('z.coerce.bigint()'); }); + // A misspelled safety key would otherwise run the tool without approval. + it.each([ + ['requireApproval', 'needsApproval'], + ['needApproval', 'needsApproval'], + ['inputSchema', 'input'], + ['parameters', 'input'], + ['handler', 'execute'], + ['deferLoad', 'deferLoading'], + ])("rejects the unknown option '%s' and suggests '%s'", (key, suggestion) => { + expect(() => defineTool({ ...base, [key]: true } as never)).toThrow( + expect.objectContaining({ code: 'LOUSHO_CONFIG_INVALID', message: expect.stringContaining(`tool 'ok': '${key}' is not a known option (did you mean '${suggestion}'?)`) }) + ); + }); + + it('rejects an unknown option with no plausible match, listing the allowed ones', () => { + expect(() => defineTool({ ...base, frobnicate: 1 } as never)).toThrow(/'frobnicate' is not a known option\. Allowed options: name, description, input, /); + }); + + it('ignores an unknown option whose value is undefined', () => { + expect(() => defineTool({ ...base, requireApproval: undefined } as never)).not.toThrow(); + }); + + it('accepts a defined tool spread back in, but not a stray inputSchema', () => { + const tool = defineTool(base); + expect(defineTool({ ...tool, execute: () => 2 }).name).toBe('ok'); + expect(() => defineTool({ ...base, inputSchema: z.object({}) } as never)).toThrow(/'inputSchema' is not a known option \(did you mean 'input'\?\)/); + }); + it('accepts z.coerce.date(), z.coerce.bigint(), refined objects and object unions', () => { expect(() => defineTool({ ...base, input: z.object({ when: z.coerce.date(), n: z.coerce.bigint() }) })).not.toThrow(); expect(() => defineTool({ ...base, input: z4.object({ when: z4.coerce.date(), n: z4.coerce.bigint() }) })).not.toThrow(); diff --git a/src/tools/defineTool.ts b/src/tools/defineTool.ts index 8c465dce..a28d409c 100644 --- a/src/tools/defineTool.ts +++ b/src/tools/defineTool.ts @@ -4,6 +4,7 @@ import { legacyAiTool } from './toolContract'; import { isModelSchema, nonObjectRoot, unrepresentableFields, type InferSchemaOutput, type StandardSchemaV1 } from '../utils/zodCompat'; import { sharedWeakSet } from '../utils/brand'; import { SDKError, ToolExecutionError } from '../execution/errors'; +import { unknownOptionKey } from '../utils/knownOptionKeys'; /** Tool names must satisfy the constraint LLM providers impose on function names. */ const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; @@ -114,12 +115,62 @@ export function isDefinedTool(value: unknown): value is DefinedTool { return typeof value === 'object' && value !== null && definedTools.has(value); } +/** Every {@link DefineToolOptions} key; `satisfies` keeps it in step with the type. */ +const DEFINE_TOOL_KEYS = { + name: true, + description: true, + input: true, + displayName: true, + needsApproval: true, + annotations: true, + editsFiles: true, + deferLoading: true, + timeoutMs: true, + requiresSandbox: true, + sandboxExecute: true, + execute: true, +} as const satisfies Record, true>; + +/** The keys a {@link DefinedTool} has beyond its options, accepted when one is spread back into defineTool(). */ +const DEFINED_TOOL_KEYS = { inputSchema: true, tool: true, metadata: true } as const; + +/** Names other tool APIs (AI SDK, MCP, OpenAI Agents) use, mapped to ours. */ +const DEFINE_TOOL_ALIASES: Readonly> = { + requireApproval: 'needsApproval', + requiresApproval: 'needsApproval', + needApproval: 'needsApproval', + inputSchema: 'input', + parameters: 'input', + schema: 'input', + args: 'input', + handler: 'execute', + run: 'execute', + invoke: 'execute', + title: 'displayName', + timeout: 'timeoutMs', +}; + function fail(problem: string, fix: string): never { throw new SDKError(`defineTool: ${problem}. ${fix}`, 'LOUSHO_CONFIG_INVALID'); } +/** + * An unknown option (a typo, or another SDK's name such as `requireApproval`) + * fails instead of being ignored. `defineTool({ ...existingTool, execute })` + * re-defines a tool, so the keys a defined tool adds are accepted when its + * `inputSchema` is the `input`, as on a spread tool. + */ +function assertKnownKeys(opts: { name?: unknown; input?: unknown; inputSchema?: unknown }): void { + const spread = opts.inputSchema === opts.input ? DEFINED_TOOL_KEYS : {}; + const unknown = unknownOptionKey(opts, { ...DEFINE_TOOL_KEYS, ...spread }, DEFINE_TOOL_ALIASES); + if (!unknown) return; + const tool = typeof opts.name === 'string' && opts.name !== '' ? `tool '${opts.name}': ` : ''; + fail(`${tool}${unknown.problem}`, `Allowed options: ${Object.keys(DEFINE_TOOL_KEYS).join(', ')}`); +} + function assertValidOptions(opts: { name?: unknown; description?: unknown; input?: unknown; execute?: unknown }): void { const { name, description, input, execute } = opts; + assertKnownKeys(opts); if (typeof name !== 'string' || name === '') { fail("'name' is required", "Example: defineTool({ name: 'send_email', ... })"); } diff --git a/src/utils/knownOptionKeys.ts b/src/utils/knownOptionKeys.ts new file mode 100644 index 00000000..0475d8e5 --- /dev/null +++ b/src/utils/knownOptionKeys.ts @@ -0,0 +1,28 @@ +/** + * Rejects option keys an API does not know, so a misspelled key from + * JavaScript, YAML or an `as any` cast fails loudly instead of being ignored + * (a mistyped `needsApproval` would otherwise run the tool without approval). + */ +import { closestMatch } from './closestMatch'; + +/** + * The first key of `options` that is not in `known`, with what is wrong with + * it ("is not a known option (did you mean 'needsApproval'?)"), or undefined + * when every key is known. `aliases` maps names other SDKs use to ours (the + * suggestion may be a path such as `modelSettings.maxTokens`); other unknown + * keys get the closest known key, if one is a plausible typo. A key whose + * value is undefined is skipped, since it sets nothing. + */ +export function unknownOptionKey( + options: object, + known: Readonly>, + aliases: Readonly> = {} +): { key: string; problem: string } | undefined { + const knownKeys = Object.keys(known); + for (const [key, value] of Object.entries(options)) { + if (value === undefined || Object.hasOwn(known, key)) continue; + const suggestion = Object.hasOwn(aliases, key) ? aliases[key] : closestMatch(key, knownKeys); + return { key, problem: `'${key}' is not a known option${suggestion ? ` (did you mean '${suggestion}'?)` : ''}` }; + } + return undefined; +}