1
0
Fork 0

Proper typechecking for tools

This commit is contained in:
Pabloader 2026-03-25 07:56:51 +00:00
parent d1325b33b7
commit af59e03212
5 changed files with 362 additions and 290 deletions

View File

@ -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=="],
}
}

258
src/common/typebox.ts Normal file
View File

@ -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<T extends TScheme = TScheme>(items: T, args: { description?: string } = {}) {
const result: TArray<T> = {
type: 'array',
items,
}
if (args.description) {
result.description = args.description;
}
return result;
}
export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
const result = { ...scheme };
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
return result as unknown as TOptional<T>;
}
export function Object<T extends Record<string, TScheme> = Record<string, TScheme>>(properties: T, args: { description?: string } = {}) {
const result: TObject<T> = {
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 | number) = (string | number)> =
T extends string ? TString<T> :
T extends number ? TNumber<T> : never;
export function Enum<T extends TString>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TNumber>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TEnumType = TEnumType>(items: Static<T>[], 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<string, unknown>))) {
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<string, unknown>)[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<T extends string = string> {
type: 'string';
enum?: T[];
description?: string;
}
export interface TNumber<T extends number = number> {
type: 'number' | 'integer';
enum?: T[];
description?: string;
}
export interface TBoolean {
type: 'boolean';
description?: string;
}
export interface TArray<T extends TScheme = TScheme> {
type: 'array';
description?: string;
items: T;
}
export interface TObject<T extends Record<string, TScheme> = Record<string, TScheme>> {
type: 'object';
description?: string;
properties: T;
required?: string[];
}
export interface TOptionalString<T extends string = string> extends TString<T> {
[optional]: true;
}
export interface TOptionalNumber<T extends number = number> extends TNumber<T> {
[optional]: true;
}
export interface TOptionalBoolean extends TBoolean {
[optional]: true;
}
export interface TOptionalArray<T extends TScheme = TScheme> extends TArray<T> {
[optional]: true;
}
export interface TOptionalObject<T extends Record<string, TScheme> = Record<string, TScheme>> extends TObject<T> {
[optional]: true;
}
export type TOptional<T extends TScheme = TScheme> =
T extends TString<infer S> ? TOptionalString<S> :
T extends TNumber<infer N> ? TOptionalNumber<N> :
T extends TBoolean ? TOptionalBoolean :
T extends TArray<infer I> ? TOptionalArray<I> :
T extends TObject<infer P> ? TOptionalObject<P> :
never;
export type IsOptional<T> = T extends { [optional]: true } ? true : false;
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject;
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type RequiredKeys<T extends Record<string, TScheme>> = {
[K in keyof T]: IsOptional<T[K]> extends true ? never : K
}[keyof T];
type OptionalKeys<T extends Record<string, TScheme>> = {
[K in keyof T]: IsOptional<T[K]> extends true ? K : never
}[keyof T];
type StaticObject<T extends Record<string, TScheme>> = Prettify<
{ [K in RequiredKeys<T>]: Static<T[K]> } &
{ [K in OptionalKeys<T>]?: Static<T[K]> }
>;
export type Static<T extends TScheme> =
T extends TString<infer S> ? S :
T extends TNumber<infer N> ? N :
T extends TBoolean ? boolean :
T extends TArray<infer I> ? Static<I>[] :
T extends TObject<infer P> ? StaticObject<P> :
never;

View File

@ -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<string, ToolParameter>;
required?: string[];
}
export type ToolParameter = ToolStringParameter | ToolNumberParameter | ToolBooleanParameter | ToolArrayParameter | ToolObjectParameter;
export interface Tool {
type: 'function';
function: {
name: string;
description?: string;
parameters: ToolObjectParameter;
parameters: TObject;
};
}

View File

@ -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,
};
}
}

View File

@ -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<T extends TObject = TObject> {
description: string;
parameters: LLM.ToolObjectParameter;
handler(args: string | Record<string, any>, appState: AppState): unknown;
parameters: T;
handler(args: Static<T>, appState: AppState): unknown;
}
const tool = <T extends TObject = TObject>(t: Tool<T>): Tool<T> => t;
const TOOLS: Record<string, Tool> = {
'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<Character> = {};
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',
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}`,
enum: VALID_SCALES
},
},
required: ['name', 'shortDescription', 'scale'],
},
},
'edit_location': {
}),
}),
}),
'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<Location> = {};
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: "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}`,
},
},
required: ['name'],
},
}
})),
}),
})
};
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;
}