From af59e0321282163b7a7400f11d1e4c3bcc9f0bb5 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 25 Mar 2026 07:56:51 +0000 Subject: [PATCH] Proper typechecking for tools --- bun.lock | 12 +- src/common/typebox.ts | 258 +++++++++++++++++++ src/games/storywriter/utils/llm.ts | 37 +-- src/games/storywriter/utils/prompt.ts | 1 + src/games/storywriter/utils/tools.ts | 344 ++++++++------------------ 5 files changed, 362 insertions(+), 290 deletions(-) create mode 100644 src/common/typebox.ts diff --git a/bun.lock b/bun.lock index 58cf289..e3bbe5a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,11 +14,11 @@ "ace-builds": "1.36.3", "clsx": "2.1.1", "delay": "6.0.0", - "lucide-preact": "^0.577.0", + "lucide-preact": "0.577.0", "preact": "10.22.0", }, "devDependencies": { - "@types/bun": "^1.3.10", + "@types/bun": "latest", "@types/html-minifier": "4.0.5", "@types/inquirer": "9.0.7", "@types/web-bluetooth": "0.0.21", @@ -49,7 +49,7 @@ "@inquirer/type": ["@inquirer/type@1.4.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/clean-css": ["@types/clean-css@4.2.11", "", { "dependencies": { "@types/node": "*", "source-map": "^0.6.0" } }, "sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw=="], @@ -81,7 +81,7 @@ "browser-detect": ["browser-detect@0.2.28", "", { "dependencies": { "core-js": "^2.5.7" } }, "sha512-KeWGHqYQmHDkCFG2dIiX/2wFUgqevbw/rd6wNi9N6rZbaSJFtG5kel0HtprRwCGp8sqpQP79LzDJXf/WCx4WAw=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "camel-case": ["camel-case@3.0.0", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.1.1" } }, "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w=="], @@ -163,8 +163,6 @@ "@types/through/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], - "bun-types/@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], - "html-minifier/uglify-js": ["uglify-js@3.18.0", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A=="], "@inquirer/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -174,7 +172,5 @@ "@types/mute-stream/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/through/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], } } diff --git a/src/common/typebox.ts b/src/common/typebox.ts new file mode 100644 index 0000000..79a081b --- /dev/null +++ b/src/common/typebox.ts @@ -0,0 +1,258 @@ +const optional = Symbol(); +const GlobalObject = Object; +const GlobalNumber = Number; +const GlobalArray = Array; + +export namespace Type { + export function String(args: { description?: string, enum?: string[] } = {}) { + const result: TString = { + type: 'string', + }; + if (args.enum) { + result.enum = args.enum; + } + if (args.description) { + result.description = args.description; + } + return result; + } + + export function Number(args: { description?: string, enum?: number[] } = {}) { + const result: TNumber = { + type: 'number', + }; + if (args.enum) { + result.enum = args.enum; + } + if (args.description) { + result.description = args.description; + } + return result; + } + + export function Integer(args: { description?: string, enum?: number[] } = {}) { + const result: TNumber = { + type: 'integer', + }; + if (args.enum) { + result.enum = args.enum; + } + if (args.description) { + result.description = args.description; + } + return result; + } + + export function Boolean(args: { description?: string } = {}) { + const result: TBoolean = { + type: 'boolean', + }; + if (args.description) { + result.description = args.description; + } + return result; + } + + export function Array(items: T, args: { description?: string } = {}) { + const result: TArray = { + type: 'array', + items, + } + if (args.description) { + result.description = args.description; + } + return result; + } + + export function Optional(scheme: T): TOptional { + const result = { ...scheme }; + GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false }); + return result as unknown as TOptional; + } + + export function Object = Record>(properties: T, args: { description?: string } = {}) { + const result: TObject = { + type: 'object', + properties, + required: GlobalObject.keys(properties) + .filter(key => !(properties[key] as any)[optional]), + } + if (args.description) { + result.description = args.description; + } + return result; + } + + type TEnumType = + T extends string ? TString : + T extends number ? TNumber : never; + + export function Enum(items: Static[], args?: { description?: string }): T; + export function Enum(items: Static[], args?: { description?: string }): T; + export function Enum(items: Static[], args: { description?: string } = {}) { + if (typeof items?.[0] === 'number') { + return Number({ enum: items.map(item => +item!), ...args }); + } + return String({ enum: items.map(item => item!.toString()), ...args }); + } + + export interface CheckError { + path: string; + message: string; + } + + function check(scheme: TScheme, value: unknown, path: string): CheckError[] { + if (value == null) { + if ((scheme as any)[optional]) return []; + return [{ path, message: `Expected ${scheme.type} at ${path}, got ${value}` }]; + } + + switch (scheme.type) { + case 'string': { + if (typeof value !== 'string') { + return [{ path, message: `Expected string at ${path}, got ${typeof value}` }]; + } + if (scheme.enum && !scheme.enum.includes(value)) { + return [{ path, message: `Expected one of [${scheme.enum.join(', ')}] at ${path}, got "${value}"` }]; + } + return []; + } + case 'number': + case 'integer': { + if (typeof value !== 'number') { + return [{ path, message: `Expected number at ${path}, got ${typeof value}` }]; + } + if (scheme.type === 'integer' && !GlobalNumber.isInteger(value)) { + return [{ path, message: `Expected integer at ${path}, got ${value}` }]; + } + const s = scheme as TNumber; + if (s.enum && !s.enum.includes(value)) { + return [{ path, message: `Expected one of [${s.enum.join(', ')}] at ${path}, got ${value}` }]; + } + return []; + } + case 'boolean': { + if (typeof value !== 'boolean') { + return [{ path, message: `Expected boolean at ${path}, got ${typeof value}` }]; + } + return []; + } + case 'array': { + if (!GlobalArray.isArray(value)) { + return [{ path, message: `Expected array at ${path}, got ${typeof value}` }]; + } + const s = scheme as TArray; + return value.flatMap((item, i) => check(s.items, item, `${path}[${i}]`)); + } + case 'object': { + if (typeof value !== 'object' || GlobalArray.isArray(value)) { + return [{ path, message: `Expected object at ${path}, got ${typeof value}` }]; + } + const s = scheme as TObject; + const errors: CheckError[] = []; + for (const key of s.required || []) { + if (!(key in (value as Record))) { + errors.push({ path: `${path}.${key}`, message: `Missing required property ${path}.${key}` }); + } + } + for (const [key, propScheme] of GlobalObject.entries(s.properties)) { + const propValue = (value as Record)[key]; + if (propValue === undefined) continue; + errors.push(...check(propScheme, propValue, `${path}.${key}`)); + } + return errors; + } + default: + return []; + } + } + + export function Check(scheme: TScheme, value: unknown): CheckError[] { + return check(scheme, value, '$'); + } +} + +export interface TString { + type: 'string'; + enum?: T[]; + description?: string; +} + +export interface TNumber { + type: 'number' | 'integer'; + enum?: T[]; + description?: string; +} + +export interface TBoolean { + type: 'boolean'; + description?: string; +} + +export interface TArray { + type: 'array'; + description?: string; + items: T; +} + +export interface TObject = Record> { + type: 'object'; + description?: string; + properties: T; + required?: string[]; +} + +export interface TOptionalString extends TString { + [optional]: true; +} + +export interface TOptionalNumber extends TNumber { + [optional]: true; +} + +export interface TOptionalBoolean extends TBoolean { + [optional]: true; +} + +export interface TOptionalArray extends TArray { + [optional]: true; +} + +export interface TOptionalObject = Record> extends TObject { + [optional]: true; +} + +export type TOptional = + T extends TString ? TOptionalString : + T extends TNumber ? TOptionalNumber : + T extends TBoolean ? TOptionalBoolean : + T extends TArray ? TOptionalArray : + T extends TObject ? TOptionalObject

: + never; + +export type IsOptional = T extends { [optional]: true } ? true : false; + +export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject; + +type Prettify = { [K in keyof T]: T[K] } & {}; + +type RequiredKeys> = { + [K in keyof T]: IsOptional extends true ? never : K +}[keyof T]; + +type OptionalKeys> = { + [K in keyof T]: IsOptional extends true ? K : never +}[keyof T]; + +type StaticObject> = Prettify< + { [K in RequiredKeys]: Static } & + { [K in OptionalKeys]?: Static } +>; + +export type Static = + T extends TString ? S : + T extends TNumber ? N : + T extends TBoolean ? boolean : + T extends TArray ? Static[] : + T extends TObject ? StaticObject

: + never; diff --git a/src/games/storywriter/utils/llm.ts b/src/games/storywriter/utils/llm.ts index a8099dd..9bf8023 100644 --- a/src/games/storywriter/utils/llm.ts +++ b/src/games/storywriter/utils/llm.ts @@ -1,5 +1,5 @@ -import { formatError } from '@common/errors'; import SSE from '@common/sse'; +import type { TObject } from '@common/typebox'; namespace LLM { export interface Connection { @@ -41,45 +41,12 @@ namespace LLM { export type ChatMessage = ChatMessageUser | ChatMessageAssistant | ChatMessageSystem | ChatMessageTool; - export interface ToolStringParameter { - type: 'string'; - enum?: string[]; - description?: string; - } - - export interface ToolNumberParameter { - type: 'number' | 'integer'; - enum?: number[]; - description?: string; - } - - export interface ToolBooleanParameter { - type: 'boolean'; - enum?: boolean[]; - description?: string; - } - - export interface ToolArrayParameter { - type: 'array'; - description?: string; - items: ToolParameter; - } - - export interface ToolObjectParameter { - type: 'object'; - description?: string; - properties: Record; - required?: string[]; - } - - export type ToolParameter = ToolStringParameter | ToolNumberParameter | ToolBooleanParameter | ToolArrayParameter | ToolObjectParameter; - export interface Tool { type: 'function'; function: { name: string; description?: string; - parameters: ToolObjectParameter; + parameters: TObject; }; } diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 613d091..737c234 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -105,6 +105,7 @@ namespace Prompt { tools: Tools.getTools(), banned_tokens: state.bannedTokens, enable_thinking: enableThinking, + max_tokens: model.max_length ? model.max_length / 2 : 2048, }; } } diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 8e43d10..82f1994 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -1,4 +1,5 @@ import { formatError } from "@common/errors"; +import { Type, type Static, type TObject } from '@common/typebox'; import { LocationScale, type AppState, type Character, type Location } from "../contexts/state"; import type LLM from "./llm"; @@ -10,29 +11,24 @@ const SCALE_DESCRIPTION = Object.entries(LocationScale) const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number'); export namespace Tools { - interface Tool { + interface Tool { description: string; - parameters: LLM.ToolObjectParameter; - handler(args: string | Record, appState: AppState): unknown; + parameters: T; + handler(args: Static, appState: AppState): unknown; } + const tool = (t: Tool): Tool => t; + const TOOLS: Record = { - 'append_to_story': { + 'append_to_story': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object' || !('text' in args)) { - return 'Error: Missing required argument "text"'; - } - const { text } = args as { text: string }; - if (typeof text !== 'string') { - return 'Error: Argument "text" must be a string'; - } if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_STORY', id: appState.currentStory.id, - text: appState.currentStory.text + text + text: appState.currentStory.text + args.text }); appState.dispatch({ type: 'SET_CURRENT_TAB', @@ -42,33 +38,19 @@ export namespace Tools { return 'Text appended successfully'; }, description: 'Append text to the current story', - parameters: { - type: 'object', - properties: { - text: { - type: 'string', - description: 'The text to append to the story', - }, - }, - required: ['text'], - }, - }, - 'append_to_lore': { + parameters: Type.Object({ + text: Type.String({ description: 'The text to append to the story' }), + }), + }), + 'append_to_lore': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object' || !('text' in args)) { - return 'Error: Missing required argument "text"'; - } - const { text } = args as { text: string }; - if (typeof text !== 'string') { - return 'Error: Argument "text" must be a string'; - } if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_LORE', id: appState.currentStory.id, - lore: appState.currentStory.lore + text + lore: appState.currentStory.lore + args.text }); appState.dispatch({ type: 'SET_CURRENT_TAB', @@ -78,42 +60,19 @@ export namespace Tools { return 'Text appended to lore successfully'; }, description: 'Append text to the story lore', - parameters: { - type: 'object', - properties: { - text: { - type: 'string', - description: 'The text to append to the lore', - }, - }, - required: ['text'], - }, - }, - 'add_character': { + parameters: Type.Object({ + text: Type.String({ description: 'The text to append to the lore' }), + }), + }), + 'add_character': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object') { - return 'Error: Missing required arguments'; - } - const { name, shortDescription, nicknames, description, relations } = args as { - name: string; - shortDescription: string; - nicknames?: string[]; - description?: string; - relations?: { name: string; relation: string }[]; - }; - if (typeof name !== 'string' || !name.trim()) { - return 'Error: Argument "name" must be a non-empty string'; - } - if (typeof shortDescription !== 'string' || !shortDescription.trim()) { - return 'Error: Argument "shortDescription" must be a non-empty string'; - } if (!appState.currentStory) { return 'Error: No story selected'; } // Filter out relations that reference non-existent characters const existingCharacterNames = appState.currentStory.characters.map(c => c.name); const invalidRelations: string[] = []; - const validRelations = (relations || []).filter(rel => { + const validRelations = (args.relations || []).filter(rel => { if (!existingCharacterNames.includes(rel.name)) { invalidRelations.push(`${rel.name} (${rel.relation})`); return false; @@ -125,10 +84,10 @@ export namespace Tools { storyId: appState.currentStory.id, character: { id: crypto.randomUUID(), - name: name.trim(), - nicknames: nicknames || [], - shortDescription: shortDescription.trim(), - description: description || '', + name: args.name.trim(), + nicknames: args.nicknames || [], + shortDescription: args.shortDescription.trim(), + description: args.description || '', relations: validRelations, }, }); @@ -137,75 +96,43 @@ export namespace Tools { id: appState.currentStory.id, tab: 'characters' }); - let message = `Character "${name.trim()}" added successfully`; + let message = `Character "${args.name.trim()}" added successfully`; if (invalidRelations.length > 0) { message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`; } return message; }, description: 'Add a new character to the story', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The character\'s full name', - }, - shortDescription: { - type: 'string', - description: 'A brief description of the character (one line)', - }, - nicknames: { - type: 'array', - items: { type: 'string' }, - description: 'Optional list of nicknames', - }, - description: { - type: 'string', - description: 'Optional full character description', - }, - relations: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string', description: 'Related character name' }, - relation: { type: 'string', description: 'Relationship type' }, - }, - }, - description: 'Optional list of relationships with other characters', - }, - }, - required: ['name', 'shortDescription'], - }, - }, - 'edit_character': { + parameters: Type.Object({ + name: Type.String({ description: "The character's full name" }), + shortDescription: Type.String({ description: 'A brief description of the character (one line)' }), + nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })), + description: Type.Optional(Type.String({ description: 'Optional full character description' })), + relations: Type.Optional(Type.Array( + Type.Object({ + name: Type.String({ description: 'Related character name' }), + relation: Type.String({ description: 'Relationship type' }), + }), + { description: 'Optional list of relationships with other characters' } + )), + }), + }), + 'edit_character': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object') { - return 'Error: Missing required arguments'; - } - const { name, shortDescription, description } = args as { - name: string; - shortDescription?: string; - description?: string; - }; - if (typeof name !== 'string' || !name.trim()) { - return 'Error: Argument "name" must be a non-empty string'; - } if (!appState.currentStory) { return 'Error: No story selected'; } - const character = appState.currentStory.characters.find(c => c.name === name.trim()); + const character = appState.currentStory.characters.find(c => c.name === args.name.trim()); if (!character) { - return `Error: Character "${name.trim()}" not found`; + return `Error: Character "${args.name.trim()}" not found`; } // Only include defined values to avoid setting fields to undefined const definedUpdates: Partial = {}; - if (shortDescription !== undefined) { - definedUpdates.shortDescription = shortDescription; + if (args.shortDescription !== undefined) { + definedUpdates.shortDescription = args.shortDescription; } - if (description !== undefined) { - definedUpdates.description = description; + if (args.description !== undefined) { + definedUpdates.description = args.description; } appState.dispatch({ type: 'EDIT_CHARACTER', @@ -218,51 +145,17 @@ export namespace Tools { id: appState.currentStory.id, tab: 'characters' }); - return `Character "${name.trim()}" updated successfully`; + return `Character "${args.name.trim()}" updated successfully`; }, - description: 'Edit an existing character\'s description', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The character\'s full name to identify which character to edit', - }, - shortDescription: { - type: 'string', - description: 'Brief description of the character (one line)', - }, - description: { - type: 'string', - description: 'Full character description', - }, - }, - required: ['name'], - }, - }, - 'add_location': { + description: "Edit an existing character's description", + parameters: Type.Object({ + name: Type.String({ description: "The character's full name to identify which character to edit" }), + shortDescription: Type.Optional(Type.String({ description: 'Brief description of the character (one line)' })), + description: Type.Optional(Type.String({ description: 'Full character description' })), + }), + }), + 'add_location': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object') { - return 'Error: Missing required arguments'; - } - const { name, shortDescription, description, scale } = args as { - name: string; - shortDescription: string; - description?: string; - scale: number; - }; - if (typeof name !== 'string' || !name.trim()) { - return 'Error: Argument "name" must be a non-empty string'; - } - if (typeof shortDescription !== 'string' || !shortDescription.trim()) { - return 'Error: Argument "shortDescription" must be a non-empty string'; - } - if (typeof scale !== 'number') { - return 'Error: Argument "scale" must be a number'; - } - if (!VALID_SCALES.includes(scale)) { - return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`; - } if (!appState.currentStory) { return 'Error: No story selected'; } @@ -271,10 +164,10 @@ export namespace Tools { storyId: appState.currentStory.id, location: { id: crypto.randomUUID(), - name: name.trim(), - shortDescription: shortDescription.trim(), - description: description || '', - scale: scale as LocationScale, + name: args.name.trim(), + shortDescription: args.shortDescription.trim(), + description: args.description || '', + scale: args.scale, }, }); appState.dispatch({ @@ -282,66 +175,36 @@ export namespace Tools { id: appState.currentStory.id, tab: 'locations' }); - return `Location "${name.trim()}" added successfully`; + return `Location "${args.name.trim()}" added successfully`; }, description: 'Add a new location to the story', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The location\'s full name', - }, - shortDescription: { - type: 'string', - description: 'A brief description of the location (one line)', - }, - description: { - type: 'string', - description: 'Optional full location description', - }, - scale: { - type: 'number', - description: `Location scale (enum): ${SCALE_DESCRIPTION}`, - enum: VALID_SCALES - }, - }, - required: ['name', 'shortDescription', 'scale'], - }, - }, - 'edit_location': { + parameters: Type.Object({ + name: Type.String({ description: "The location's full name" }), + shortDescription: Type.String({ description: 'A brief description of the location (one line)' }), + description: Type.Optional(Type.String({ description: 'Optional full location description' })), + scale: Type.Enum(VALID_SCALES, { + description: `Location scale (enum): ${SCALE_DESCRIPTION}`, + }), + }), + }), + 'edit_location': tool({ handler: async (args, appState) => { - if (!args || typeof args !== 'object') { - return 'Error: Missing required arguments'; - } - const { name, shortDescription, description, scale } = args as { - name: string; - shortDescription?: string; - description?: string; - scale?: number; - }; - if (typeof name !== 'string' || !name.trim()) { - return 'Error: Argument "name" must be a non-empty string'; - } if (!appState.currentStory) { return 'Error: No story selected'; } - const location = appState.currentStory.locations.find(l => l.name === name.trim()); + const location = appState.currentStory.locations.find(l => l.name === args.name.trim()); if (!location) { - return `Error: Location "${name.trim()}" not found`; + return `Error: Location "${args.name.trim()}" not found`; } const definedUpdates: Partial = {}; - if (shortDescription !== undefined) { - definedUpdates.shortDescription = shortDescription; + if (args.shortDescription !== undefined) { + definedUpdates.shortDescription = args.shortDescription; } - if (description !== undefined) { - definedUpdates.description = description; + if (args.description !== undefined) { + definedUpdates.description = args.description; } - if (scale !== undefined) { - if (!VALID_SCALES.includes(scale)) { - return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`; - } - definedUpdates.scale = scale as LocationScale; + if (args.scale !== undefined) { + definedUpdates.scale = args.scale; } appState.dispatch({ type: 'EDIT_LOCATION', @@ -354,32 +217,18 @@ export namespace Tools { id: appState.currentStory.id, tab: 'locations' }); - return `Location "${name.trim()}" updated successfully`; + return `Location "${args.name.trim()}" updated successfully`; }, - description: 'Edit an existing location\'s description', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The location\'s full name to identify which location to edit', - }, - shortDescription: { - type: 'string', - description: 'Brief description of the location (one line)', - }, - description: { - type: 'string', - description: 'Full location description', - }, - scale: { - type: 'number', - description: `Location scale (enum): ${SCALE_DESCRIPTION}`, - }, - }, - required: ['name'], - }, - } + description: "Edit an existing location's description", + parameters: Type.Object({ + name: Type.String({ description: "The location's full name to identify which location to edit" }), + shortDescription: Type.Optional(Type.String({ description: 'Brief description of the location (one line)' })), + description: Type.Optional(Type.String({ description: 'Full location description' })), + scale: Type.Optional(Type.Enum(VALID_SCALES, { + description: `Location scale (enum): ${SCALE_DESCRIPTION}`, + })), + }), + }) }; export function getTools(): LLM.Tool[] { @@ -418,17 +267,18 @@ export namespace Tools { const { function: fn } = toolCall; const args = parseArg(fn.arguments); - if (!args || typeof args !== 'object') { - return 'Error: Arguments must be an object'; - } - - const handler = TOOLS[fn.name]?.handler; - if (!handler) { + const tool = TOOLS[fn.name]; + if (!tool) { return `Unknown tool: ${fn.name}`; } + const errors = Type.Check(tool.parameters, args); + if (errors.length > 0) { + return errors.map(e => e.message).join('\n'); + } + try { - const result = await handler(args, appState); + const result = await tool.handler(args as any, appState); if (typeof result === 'string') { return result; }