Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/createAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/createAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -942,6 +943,7 @@ export function createAgentConfigOf(agent: SimpleAgent): CreateAgentConfig | und
export function createAgent<TOutput extends StandardSchemaV1 = StandardSchemaV1>(
config: CreateAgentConfig<TOutput> = {}
): SimpleAgent<InferSchemaOutput<TOutput>> {
assertKnownCreateAgentKeys(config);
assertToolConcurrency(config.toolConcurrency, 'createAgent');
assertMaxSteps(config.maxSteps, 'createAgent');
assertModelSettings(config.modelSettings, 'createAgent');
Expand Down
84 changes: 84 additions & 0 deletions src/createAgentKeys.ts
Original file line number Diff line number Diff line change
@@ -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<keyof CreateAgentConfig, true>;

/** 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<Exclude<keyof ClaudeProject, keyof CreateAgentConfig>, true>;

/** Names other agent SDKs use at the top level, mapped to ours (sampling settings live in `modelSettings`). */
const CREATE_AGENT_ALIASES: Readonly<Record<string, string>> = {
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);
}
2 changes: 1 addition & 1 deletion src/execution/modelSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const SETTING_RANGES: Readonly<Partial<Record<string, readonly [number, number]>
};

/** AI SDK (and provider API) names people reach for, mapped to ours. */
const SETTING_SYNONYMS: Readonly<Record<string, keyof ModelSettings>> = {
export const SETTING_SYNONYMS: Readonly<Record<string, keyof ModelSettings>> = {
maxOutputTokens: 'maxTokens',
max_tokens: 'maxTokens',
max_completion_tokens: 'maxTokens',
Expand Down
28 changes: 28 additions & 0 deletions src/tools/defineTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
51 changes: 51 additions & 0 deletions src/tools/defineTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand Down Expand Up @@ -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<keyof DefineToolOptions<StandardSchemaV1, unknown>, 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<Record<string, string>> = {
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', ... })");
}
Expand Down
28 changes: 28 additions & 0 deletions src/utils/knownOptionKeys.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, true>>,
aliases: Readonly<Record<string, string>> = {}
): { 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;
}
Loading