diff --git a/.gitignore b/.gitignore index fddbd5c..7b0d94b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ dist/ scratchpad.md **/.gitignore -**/.env \ No newline at end of file +**/.env +debug.log \ No newline at end of file diff --git a/README.md b/README.md index bcc7be6..5d56168 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ For more information, see the [documentation](docs/introduction.md). Try the example chat interface: ```bash +npm install && npm run build cd cli npm install echo "OPENAI_API_KEY=your_key_here" > .env diff --git a/cli/package.json b/cli/package.json index 9cd1ee4..1bd0be7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -4,7 +4,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev": "vite-node -w ./src/index.tsx" + "dev": "vite-node ./src/index.tsx" }, "author": "", "license": "ISC", diff --git a/cli/src/components/MessageBlock.tsx b/cli/src/components/MessageBlock.tsx index f139623..753eb18 100644 --- a/cli/src/components/MessageBlock.tsx +++ b/cli/src/components/MessageBlock.tsx @@ -44,6 +44,7 @@ export const MessageBlock = React.memo(({ msg }: { msg: Message }) => { } if (msg.type === 'tool-results') { + console.log(msg); return ( diff --git a/cli/src/tools/async-test-tool.ts b/cli/src/tools/async-test-tool.ts new file mode 100644 index 0000000..6a7b4b8 --- /dev/null +++ b/cli/src/tools/async-test-tool.ts @@ -0,0 +1,14 @@ +import { createTool, createPromiseProcess } from '@unternet/kernel'; +import { z } from 'zod'; + +export default createTool({ + name: 'async_test_tool', + description: 'Test async tool-calling functionality.', + parameters: z.object({ name: z.string() }), + execute: () => + createPromiseProcess('async_test_tool', () => { + return new Promise((resolve) => { + setTimeout(() => resolve('Test completed successfully!'), 4000); + }); + }), +}); diff --git a/cli/src/tools/deep-research.ts b/cli/src/tools/deep-research.ts index 9759f79..7007097 100644 --- a/cli/src/tools/deep-research.ts +++ b/cli/src/tools/deep-research.ts @@ -1,4 +1,4 @@ -import { createTool, PromiseProcess } from '@unternet/kernel'; +import { createPromiseProcess, createTool } from '@unternet/kernel'; import { getJson } from 'serpapi'; import { z } from 'zod'; @@ -38,20 +38,6 @@ export default createTool({ description: 'Perform a research investigation for a more thorough answer.', parameters: z.object({ query: z.string() }), execute: ({ query }) => { - return new PromiseProcess('deep_research', () => deepResearch(query)); + createPromiseProcess('deep_research', () => deepResearch(query)); }, }); - -// Consider having a `target:` property here -// We send a .call() to the target when it's instantiated -// That means the promise process/runtime can emit an event that contains -// all the call details, for the response message. -// We might actually want runtime.call(id, toolCall)? - -// Or maybe we can just receive the call in execute, and -// run the call here? Then we can have everything responding to execute. - -// Oh! Maybe if we return a Callable (e.g. Process), we then run call on that -// once we receive it and spawn it properly! - -// Ah but then we can't encapsulate the real tool name in here... diff --git a/cli/src/tools/index.ts b/cli/src/tools/index.ts index 2010f0f..913e57d 100644 --- a/cli/src/tools/index.ts +++ b/cli/src/tools/index.ts @@ -1,5 +1,6 @@ import { Tool } from '@unternet/kernel'; import deepResearchTool from './deep-research'; +import asyncTestTool from './async-test-tool'; import testTool from './test-tool'; -export const tools = [testTool, deepResearchTool] as Tool[]; +export const tools = [testTool, asyncTestTool, deepResearchTool] as Tool[]; diff --git a/docs/introduction.md b/docs/introduction.md index d202e3e..ce38f02 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -87,7 +87,7 @@ If the kernel receives a `Process` object after executing the tool, it will spin const meaningOfLifeTool = createTool({ name: 'meaning_of_life', description: 'Get the answer to the meaning of life, the universe, and everything', - execute: () => return new MeaningOfLifeProcess(), + execute: () => return MeaningOfLifeProcess, }); const tools = [meaningOfLifeTool]; diff --git a/src/index.ts b/src/index.ts index 01dcbcd..4d9bb99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ export * from './kernel'; export * from './types'; +export * from './processes'; export * from './messages'; export * from './stream'; export * from './tools'; export * from './emitter'; -export * from './processes'; export * from './promise-process'; diff --git a/src/kernel.ts b/src/kernel.ts index 9829385..748d305 100644 --- a/src/kernel.ts +++ b/src/kernel.ts @@ -5,8 +5,11 @@ import { Tool } from './tools'; import { createMessage, Message, ReplyMessage } from './messages'; import { ToolCall, ToolResult } from './tools'; import { Emitter } from './emitter'; -import { Process } from './processes'; +import { Process } from './processes/process'; +import { ProcessContainer } from './processes/process-container'; import { DEFAULT_MESSAGE_LIMIT } from './constants'; +import { ulid } from 'ulid'; +import { isProcessConstructor } from './processes'; export interface KernelOpts { model: LanguageModel; @@ -25,11 +28,12 @@ type KernelStatus = 'idle' | 'busy'; export class Kernel extends Emitter { model: LanguageModel; - tools: Tool[] = []; messageLimit: number = DEFAULT_MESSAGE_LIMIT; runtime = new Runtime(); + tools: Tool[] = []; spawn = this.runtime.spawn.bind(this.runtime); restore = this.runtime.restore.bind(this.runtime); + kill = this.runtime.kill.bind(this.runtime); registerProcessConstructor = this.runtime.registerProcessConstructor.bind( this.runtime ); @@ -44,21 +48,42 @@ export class Kernel extends Emitter { if (opts.tools) this.tools = opts.tools; if (opts.messages) this.messages = opts.messages; - this.runtime.on('process.created', (e) => { - this.emit('process.created', e); + this.runtime.on('process-created', (e) => { + this.emit('process-created', e); }); - this.runtime.on('process.changed', (e) => { - this.emit('process.changed', e); + this.runtime.on('process-changed', (e) => { + this.emit('process-changed', e); }); - this.runtime.on('process.exited', (e) => { - this.emit('process.exited', e); + this.runtime.on('process-exited', (e) => { + this.emit('process-exited', e); }); - this.runtime.on('process.tool-result', (e) => { - // TODO: Propert implementation of process results + this.runtime.on('tool-result', (e) => { + const callId = ulid(); + + // TODO: Make this actually reflect the tool call better + // Can we have multiple outputs to one tool call? + this.addMessage( + createMessage('tool-calls', { + calls: [ + { + id: callId, + name: e.result.name || '', + args: {}, + }, + ], + }) + ); + this.send( - createMessage('system', { - text: `Tool call completed.`, + createMessage('tool-results', { + results: [ + { + callId, + name: e.result.name || '', + output: e.result.output, + }, + ], }) ); }); @@ -124,18 +149,23 @@ export class Kernel extends Emitter { // Handle deltas if (response.type === 'delta') { + // Check if this is is the first chunk if (!this._messages.has(response.id)) { - const initialMsg: ReplyMessage = { - type: 'reply', - id: response.id, - timestamp: response.timestamp, - text: '', - }; - this.addMessage(initialMsg); + // Only process reply deltas at this stage + if (response.messageType === 'reply') { + const initialMsg: ReplyMessage = { + type: 'reply', + id: response.id, + timestamp: response.timestamp, + text: '', + }; + this.addMessage(initialMsg); + } } let msg = this._messages.get(response.id); + // We have already received the first chunk, now append if (msg && msg.type === 'reply') { const delta = response.delta as Partial; @@ -173,22 +203,20 @@ export class Kernel extends Emitter { const { id, name, args } = call; const tool = this.tools.find((t) => t.name === name); - if (!tool?.execute) { - return; - // throw new Error(`Unknown or invalid tool: ${name}`); - } + if (!tool?.execute) return; - let rawOutput = await tool.execute(args); + let output = await tool.execute(args); - const output = - rawOutput instanceof Process - ? this.runtime.spawn(rawOutput) - : rawOutput; + let container: ProcessContainer | null = null; + if (isProcessConstructor(output)) { + container = this.runtime.spawn(output); + container.call(call); + } results.push({ callId: id, name: name, - output, + output: container ?? output, }); } diff --git a/src/messages.ts b/src/messages.ts index fd912e4..a9b05fc 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -7,7 +7,7 @@ import { CoreUserMessage, } from 'ai'; import { ToolCall, ToolResult } from './tools'; -import { ProcessContainer } from './processes'; +import { ProcessContainer } from './processes/process-container'; export type Message = | SystemMessage diff --git a/src/processes.ts b/src/processes.ts deleted file mode 100644 index fa334fd..0000000 --- a/src/processes.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { ulid } from 'ulid'; -import { Emitter } from './emitter'; -import { ResourceIcon } from './resources'; -import { JSONValue } from 'ai'; -import { ToolCall, ToolResult } from './tools'; - -export type ProcessStatus = 'running' | 'suspended'; -export type ProcessConstructor = new (state?: any) => Process; - -// These are properties that all processes are expected to implement -// ...but they're optional because this is the web after all. Do what you want. -export interface ProcessMetadata { - name?: string; - icons?: ResourceIcon[]; -} - -// This is what a ProcessContainer will save & rehydrate from. -// The inner process only sees what's inside 'state' -export interface ProcessSnapshot extends ProcessMetadata { - id: string; - type: string; - status: ProcessStatus; - state: JSONValue; -} - -export type ProcessEvents = { - change: undefined; - 'tool-result': ToolResult; -}; - -/** - * A process is a long-running task that can be suspended or resumed, - * and serialized. - */ -export class Process - extends Emitter - implements ProcessMetadata -{ - type: string | null = null; - name?: string; - icons?: ResourceIcon[]; - suspendable: boolean = true; - state?: T; - - constructor(state?: T) { - super(); - if (state) this.state = state; - } - - // Overridden by process container - exit(): void {} - - /** - * Instantiate a new process, based on serialized state from serialize() - */ - static fromSnapshot(state: unknown): Process { - return new Process(state); - } - - /** - * This is run whenever the process is started or resumed. - */ - activate(): void | Promise {} - - /** - * This is run whenever the process is suspended or exited. - */ - deactivate(): void | Promise {} - - /** - * Update the current state of the process & notify listeners. - */ - setState(newState: T) { - this.state = newState; - this.emit('change'); - } - - /** - * Describe the process to the model. - */ - describe(): JSONValue { - return this.serialize(); - } - - /** - * Call a tool on this process. - */ - // TODO - call(toolCall: ToolCall) {} - - /** - * Return a snapshot of serialiable data, for rehydration. - */ - serialize(): JSONValue { - if (this.state) return this.state; - return null; - } -} - -export type ProcessContainerEvents = ProcessEvents & { - resume: undefined; - suspend: undefined; - exit: undefined; -}; - -export class ProcessContainer - extends Emitter - implements ProcessMetadata -{ - readonly type: string; - private _id: string; - private _status: ProcessStatus = 'suspended'; - private _process?: Process; - private _snapshot?: ProcessSnapshot; - - get id() { - return this._id; - } - get status() { - return this._status; - } - get name() { - return this._process?.name; - } - get icons() { - return this._process?.icons; - } - get snapshot() { - return this._snapshot; - } - - constructor(process: Process, id: string = ulid()) { - super(); - this._id = id; - if (!process.type) - throw new Error(`Processes must specify a type property.`); - this.type = process.type; - this._process = process; - this._snapshot = this.serialize(); - // TODO - process.on('change', () => this.emit('change')); - process.exit = () => this.exit(); - } - - describe() { - return this._process?.describe(); - } - - async call(toolCall: ToolCall) { - // TODO - const output = await this._process?.call(toolCall); - this.emit('tool-result', { - callId: toolCall.id, - output, - }); - } - - // TODO - suspend() {} - handleSuspend() { - this._process?.deactivate(); - this.emit('suspend'); - } - - resume() {} - handleResume() { - this._process?.activate(); - this.emit('resume'); - } - - exit() {} - handleExit() { - this._process?.deactivate(); - this.emit('exit'); - } - - serialize() { - return { - id: this.id, - type: this.type, - status: this.status, - state: this._process?.serialize() || {}, - }; - } -} diff --git a/src/processes/index.ts b/src/processes/index.ts new file mode 100644 index 0000000..5b588d3 --- /dev/null +++ b/src/processes/index.ts @@ -0,0 +1,3 @@ +export * from './process'; +export * from './process-container'; +export * from './shared'; diff --git a/src/processes/process-container.ts b/src/processes/process-container.ts new file mode 100644 index 0000000..b04476f --- /dev/null +++ b/src/processes/process-container.ts @@ -0,0 +1,127 @@ +import { ulid } from 'ulid'; +import { Emitter } from '../emitter'; +import { ToolCall, ToolResult } from '../tools'; +import { Process } from './process'; +import { + ProcessConstructor, + ProcessMetadata, + ProcessSnapshot, + ProcessStatus, +} from './shared'; + +export type ProcessContainerEvents = { + change: undefined; + 'tool-result': { result: ToolResult }; + resume: undefined; + suspend: undefined; + exit: undefined; +}; + +export class ProcessContainer + extends Emitter + implements ProcessMetadata +{ + readonly type?: string; + private _id: string; + private _status: ProcessStatus = 'suspended'; + private _process?: Process; + private _processCtor?: ProcessConstructor; + private _snapshot?: ProcessSnapshot; + + get id() { + return this._id; + } + get status() { + return this._status; + } + get name() { + return this._process?.name || this._snapshot?.state.name; + } + get icons() { + return this._process?.icons || this._snapshot?.state.icons; + } + get snapshot() { + return this._process?.serialize() || this._snapshot; + } + + constructor(ctor: ProcessConstructor, snapshot?: ProcessSnapshot) { + super(); + this._id = snapshot?.id || ulid(); + this._processCtor = ctor; + this.type = this._processCtor.type; + this._snapshot = snapshot; + + // Properties/functions on Process get automatically reflected to + // ProcessContainer + return new Proxy(this, { + get(target, prop, receiver) { + if (prop in target) return Reflect.get(target, prop, receiver); + + if (target._process && prop in target._process) { + const value = (target._process as any)[prop]; + if (typeof value === 'function') return value.bind(target._process); + return value; + } + + return undefined; + }, + }); + } + + describe() { + return this._process?.describe(); + } + + async call(toolCall: ToolCall) { + const result = { + callId: toolCall.id, + name: toolCall.name, + output: await this._process?.call(toolCall), + }; + this.emit('tool-result', { result }); + } + + suspend() { + // Overridden by Runtime + } + handleSuspend() { + this._snapshot = this._process?.serialize(); + this._process?.deconstructor(); + delete this._process; + this._status = 'suspended'; + this.emit('suspend'); + } + + resume() { + // Overridden by Runtime + } + handleResume() { + if (!this._processCtor) { + throw new Error('Cannot resume process without a constructor.'); + } + + this._process = new this._processCtor(this._snapshot); + this._process.notifyChange = () => this.emit('change'); + this._process.exit = () => this.exit(); + this._snapshot = this._process.serialize(); + this._status = 'running'; + this.emit('resume'); + } + + exit() { + // Overriden by Runtime + } + handleExit() { + this._process?.deconstructor(); + this.emit('exit'); + } + + serialize(): ProcessSnapshot { + return { + id: this.id, + type: this.type, + status: this.status, + state: this._process?.serialize() || {}, + }; + } +} diff --git a/src/processes/process.ts b/src/processes/process.ts new file mode 100644 index 0000000..6629ffb --- /dev/null +++ b/src/processes/process.ts @@ -0,0 +1,63 @@ +import { ResourceIcon } from '../resources'; +import { JSONValue } from '../types'; +import { Tool, ToolCall, ToolResult } from '../tools'; +import { ProcessMetadata } from './shared'; + +/** + * A process is a long-running task that can be suspended or resumed, + * and serialized. + */ +export class Process implements ProcessMetadata { + static type?: string; + name?: string; + icons?: ResourceIcon[]; + tools: Tool[] = []; + suspendable: boolean = true; + + /** + * Runs whenever the process is created or resumed. + */ + constructor(snapshot?: SnapshotType) {} + + // TODO + notifyChange(): void { + // Overriden by ProcessContainer + } + + exit(): void { + // Overriden by ProcessContainer + } + + /** + * This is run whenever the process is suspended or exited. + */ + deconstructor(): void {} + + /** + * Describe the process to the model. + */ + describe(): JSONValue { + return this.serialize() || {}; + } + + /** + * Call a tool on this process. + */ + async call(toolCall: ToolCall) { + const tool = this.tools.find((t) => t.name === toolCall.name); + + if (!tool) { + throw new Error(`No tool named '${toolCall.name}'.`); + } + if (!tool.execute) return; + + return await tool.execute(toolCall.args); + } + + /** + * Return a snapshot of serialiable data, for rehydration. + */ + serialize(): SnapshotType | Record { + return {}; + } +} diff --git a/src/processes/shared.ts b/src/processes/shared.ts new file mode 100644 index 0000000..ce4e279 --- /dev/null +++ b/src/processes/shared.ts @@ -0,0 +1,34 @@ +import { ResourceIcon } from '../resources'; +import { JSONValue } from '../types'; +import { Process } from './process'; + +export type ProcessStatus = 'running' | 'suspended'; + +export interface ProcessConstructor { + new (...args: any[]): Process; + type: string; // Include static properties you need +} + +// These are properties that all processes are expected to implement +// ...but they're optional because this is the web after all. Do what you want. +export interface ProcessMetadata { + name?: string; + icons?: ResourceIcon[]; +} + +// This is what a ProcessContainer will save & rehydrate from. +// The inner process only sees what's inside 'state' +export interface ProcessSnapshot extends ProcessMetadata { + id: string; + type?: string; + status: ProcessStatus; + state: any; +} + +export function isProcessConstructor(value: any): value is ProcessConstructor { + return ( + typeof value === 'function' && + value.prototype && + (value === Process || value.prototype instanceof Process) + ); +} diff --git a/src/promise-process.ts b/src/promise-process.ts index ac29468..ece58eb 100644 --- a/src/promise-process.ts +++ b/src/promise-process.ts @@ -1,24 +1,25 @@ -import { Process } from './processes'; +import { Process } from './processes/process'; +import { JSONValue } from './types'; -interface PromiseProcessState { - status: string; - output?: unknown; -} - -export class PromiseProcess extends Process { - state = { status: 'in progress' }; - suspendable = false; +export function createPromiseProcess( + name: string, + promise: () => Promise +) { + return class PromiseProcess extends Process { + name = name; + promise = promise; + complete: boolean = false; + suspendable = false; - constructor(name: string, promise: () => Promise) { - super(); - this.name = name; - this.await(promise); - } + async call() { + const value = await this.promise(); + this.complete = true; + this.notifyChange(); + return value; + } - async await(promise: () => Promise) { - const output = await promise(); - this.setState({ status: 'completed', output }); - this.emit('tool-result', { output }); - this.exit(); - } + describe() { + return this.complete ? 'Complete.' : 'Working...'; + } + }; } diff --git a/src/resources.ts b/src/resources.ts index d8cb7d1..7e504e7 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -1,6 +1,7 @@ import { Tool, ToolCall } from './tools'; +import { JSONValue } from './types'; -export interface ResourceIcon { +export interface ResourceIcon extends Record { src: string; purpose?: string; sizes?: string; diff --git a/src/runtime.ts b/src/runtime.ts index d6ded24..fec27bb 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,89 +1,111 @@ import { Emitter } from './emitter'; -import { - Process, - ProcessContainer, - ProcessSnapshot, - ProcessConstructor, -} from './processes'; +import { Process } from './processes/process'; +import { ProcessContainer } from './processes/process-container'; +import { ProcessConstructor, ProcessSnapshot } from './processes/shared'; import { ToolResult } from './tools'; export type RuntimeEvents = { - 'process.created': { pid: ProcessContainer['id']; process: ProcessContainer }; - 'process.restored': { - pid: ProcessContainer['id']; - process: ProcessContainer; - }; - 'process.resumed': { pid: ProcessContainer['id']; process: ProcessContainer }; - 'process.changed': { pid: ProcessContainer['id']; process: ProcessContainer }; - 'process.exited': { pid: ProcessContainer['id'] }; - 'process.tool-result': { pid: ProcessContainer['id']; result: ToolResult }; + 'process-created': { process: ProcessContainer }; + 'process-restored': { process: ProcessContainer }; + 'process-resumed': { process: ProcessContainer }; + 'process-changed': { process: ProcessContainer }; + 'process-suspended': { process: ProcessContainer }; + 'process-exited': { pid: ProcessContainer['id'] }; + 'tool-result': { result: ToolResult }; }; export class Runtime extends Emitter { private _processes = new Map(); - private constructors = new Map(); + private ctors = new Map(); get processes(): ProcessContainer[] { return Array.from(this._processes.values()); } - registerProcessConstructor(type: string, ctor: ProcessConstructor) { - if (this.constructors.has(type)) { + registerProcessConstructor(ctor: ProcessConstructor) { + const type = ctor.type; + + if (!type) { + throw new Error( + `Tried to register process constructor with no static type property.` + ); + } + + if (this.ctors.has(type)) { throw new Error(`Constructor already registered for type '${type}'.`); } - this.constructors.set(type, ctor); + this.ctors.set(type, ctor); } - spawn(process: Process) { - const container = this.registerProcess(process); - this.emit('process.created', { pid: container.id, process: container }); + spawn(ctor: ProcessConstructor) { + const container = this.attachProcess(ctor); + this.emit('process-created', { process: container }); container.resume(); return container; } - private registerProcess( - process: Process, - id?: ProcessContainer['id'] + restore(snapshot: ProcessSnapshot) { + if (!snapshot.type) { + throw new Error(`No type specified for snapshot with ID ${snapshot.id}.`); + } + + if (!this.ctors.has(snapshot.type)) { + throw new Error( + `No constructor found for process type ${snapshot.type}.` + ); + } + + const ctor = this.ctors.get(snapshot.type)!; + const container = this.attachProcess(ctor, snapshot); + this.emit('process-restored', { process: container }); + + return container; + } + + private attachProcess( + ctor: ProcessConstructor, + snapshot?: ProcessSnapshot ): ProcessContainer { - const container = new ProcessContainer(process, id); + const container = new ProcessContainer(ctor, snapshot); container.on('change', () => { - this.emit('process.changed', { pid: container.id, process: container }); + this.emit('process-changed', { process: container }); }); container.on('resume', () => { - this.emit('process.resumed', { pid: container.id, process: container }); + this.emit('process-resumed', { process: container }); }); - container.on('exit', () => { - this._processes.delete(container.id); - this.emit('process.exited', { pid: container.id }); + container.on('suspend', () => { + this.emit('process-suspended', { process: container }); }); - container.on('tool-result', (result: ToolResult) => { - this.emit('process.tool-result', { pid: container.id, result }); + container.on('tool-result', (e) => { + this.emit('tool-result', e); }); - this._processes.set(container.id, container); - return container; - } + container.on('exit', () => { + this.emit('process-exited', { pid: container.id }); + }); - restore(snapshot: ProcessSnapshot) { - if (!this.constructors.has(snapshot.type)) { - throw new Error( - `No constructor found for process type ${snapshot.type}.` - ); - } + container.suspend = () => { + // TODO: Logic to decide whether/when to suspend + container.handleSuspend(); + }; - const ctor = this.constructors.get(snapshot.type)!; - const process = new ctor(snapshot.state); - process.name = snapshot.name; - process.icons = snapshot.icons; + container.resume = () => { + // TODO: Logic to decide whether/when to resume + container.handleResume(); + }; - const container = this.registerProcess(process, snapshot.id); - this.emit('process.restored', { pid: container.id, process: container }); + container.exit = () => { + // TODO: Logic to decide whether/when to exit + container.handleExit(); + this._processes.delete(container.id); + }; + this._processes.set(container.id, container); return container; } diff --git a/src/tools.ts b/src/tools.ts index 73634a4..a42d086 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,6 +1,6 @@ import { ToolSet } from 'ai'; -import { z, ZodSchema, ZodType, ZodTypeDef } from 'zod'; -import { Process, ProcessContainer } from './processes'; +import { z, ZodType, ZodTypeDef } from 'zod'; +import { Process } from './processes/process'; import { JSONValue } from './types'; export interface Tool { @@ -14,7 +14,7 @@ export interface Tool { : Schema extends undefined ? Record : JSONValue - ) => JSONValue | Promise | Process; + ) => any; } export interface ToolCall { @@ -25,6 +25,7 @@ export interface ToolCall { export interface ToolResult { output: any; + pid?: string; name?: string; callId?: string; error?: Error; @@ -44,7 +45,7 @@ export function createTool(tool: { type?: string; description?: string; parameters: any; - execute?: (args: any) => JSONValue | Promise | Process | void; + execute?: (args: any) => any; }): Tool; export function createTool(tool: any): any { return tool; diff --git a/src/types.ts b/src/types.ts index ccecef6..c11f78d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export { type LanguageModel } from 'ai'; export type JSONValue = + | undefined | string | number | boolean diff --git a/test/interpreter.test.ts b/test/interpreter.test.ts deleted file mode 100644 index 9def4dc..0000000 --- a/test/interpreter.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Kernel } from '../src/kernel.js'; -import { - createMessage, - InputMessage, - ToolResultsMessage, -} from '../src/messages.js'; - -describe('Kernel', () => { - let kernel: Kernel; - - beforeEach(() => { - // Use a minimal mock model that doesn't require the ai package - const mockModel = { - modelId: 'test-model', - provider: 'test', - } as any; - - kernel = new Kernel({ - model: mockModel, - tools: [], - }); - }); - - describe('constructor and initial state', () => { - it('initializes with idle status', () => { - expect(kernel.status).toBe('idle'); - }); - - it('stores model and tools', () => { - expect(kernel.model).toBeDefined(); - expect(kernel.tools).toEqual([]); - }); - - it('initializes runtime and processes', () => { - expect(kernel.runtime).toBeDefined(); - expect(kernel.processes).toEqual([]); - }); - }); - - describe('event emission', () => { - it('emits status events when status changes', () => { - const events: string[] = []; - - kernel.on('idle', () => events.push('idle')); - kernel.on('busy', () => events.push('busy')); - - // Private method access for testing status changes - (kernel as any).setStatus('busy'); - (kernel as any).setStatus('idle'); - - expect(events).toEqual(['busy', 'idle']); - }); - - it('emits process-changed events', () => { - let emitted = false; - kernel.on('process-changed', () => { - emitted = true; - }); - - // This would typically be triggered by process operations - (kernel.runtime as any).emit('process-changed', { pid: 'test-pid' }); - expect(emitted).toBe(true); - }); - }); - - describe('message validation', () => { - it('creates valid input messages', () => { - const inputMessage: InputMessage = createMessage('input', { - text: 'Hello world', - }); - - expect(inputMessage.type).toBe('input'); - expect(inputMessage.text).toBe('Hello world'); - expect(inputMessage.id).toBeDefined(); - expect(inputMessage.timestamp).toBeTypeOf('number'); - }); - - it('creates valid tool results messages', () => { - const toolResultsMessage: ToolResultsMessage = createMessage( - 'tool-results', - { - results: [ - { - callId: 'test-call', - name: 'test-tool', - output: 'test result', - }, - ], - } - ); - - expect(toolResultsMessage.type).toBe('tool-results'); - expect(toolResultsMessage.results).toHaveLength(1); - expect(toolResultsMessage.results[0].callId).toBe('test-call'); - }); - }); - - describe('tool configuration', () => { - it('accepts tools in constructor', () => { - const tools = [ - { - name: 'test-tool', - description: 'A test tool', - execute: () => 'test result', - }, - ]; - - const kernelWithTools = new Kernel({ - model: { modelId: 'test', provider: 'test' } as any, - tools, - }); - - expect(kernelWithTools.tools).toEqual(tools); - }); - }); -}); diff --git a/test/messages.test.ts b/test/messages.test.ts deleted file mode 100644 index 4b20819..0000000 --- a/test/messages.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - createMessage, - InputMessage, - LogMessage, - ReasoningMessage, - ReplyMessage, - ToolCallsMessage, - ToolResultsMessage, - SystemMessage, -} from '../src/messages.js'; -import { ToolCall, ToolResult } from '../src/tools.js'; - -describe('createInputMessage', () => { - it('creates input message with text and required fields', () => { - const message: InputMessage = createMessage('input', { - text: 'Hello world', - }); - - expect(message.type).toBe('input'); - expect(message.text).toBe('Hello world'); - expect(message.id).toBeDefined(); - expect(message.timestamp).toBeTypeOf('number'); - }); - - it('creates input message with files', () => { - const files = [ - { - data: new Uint8Array([1, 2, 3]), - filename: 'test.txt', - mimeType: 'text/plain', - }, - ]; - - const message: InputMessage = createMessage('input', { - files, - }); - - expect(message.type).toBe('input'); - expect(message.files).toEqual(files); - expect(message.text).toBeUndefined(); - }); - - it('creates input message with both text and files', () => { - const files = [{ data: new Uint8Array([1, 2, 3]) }]; - const message: InputMessage = createMessage('input', { - text: 'Check this file', - files, - }); - - expect(message.text).toBe('Check this file'); - expect(message.files).toEqual(files); - }); -}); - -describe('createReplyMessage', () => { - it('creates a reply message with text', () => { - const message: ReplyMessage = createMessage('reply', { - text: 'This is a reply.', - }); - - expect(message.type).toBe('reply'); - expect(message.text).toBe('This is a reply.'); - expect(message.id).toBeDefined(); - }); -}); - -describe('createReasoningMessage', () => { - it('creates a reasoning message with title and summary', () => { - const message: ReasoningMessage = createMessage('reasoning', { - title: 'Thinking...', - summary: 'I am thinking about the problem.', - }); - - expect(message.type).toBe('reasoning'); - expect(message.title).toBe('Thinking...'); - expect(message.summary).toBe('I am thinking about the problem.'); - expect(message.id).toBeDefined(); - }); -}); - -describe('createLogMessage', () => { - it('creates a log message with text', () => { - const message: LogMessage = createMessage('log', { - text: 'An event occurred.', - }); - - expect(message.type).toBe('log'); - expect(message.text).toBe('An event occurred.'); - expect(message.id).toBeDefined(); - }); -}); - -describe('createToolCallsMessage', () => { - it('creates tool calls message with calls array', () => { - const calls: ToolCall[] = [ - { id: 'call_1', name: 'get_weather', args: { city: 'San Francisco' } }, - { id: 'call_2', name: 'get_time', args: { timezone: 'PST' } }, - ]; - const message: ToolCallsMessage = createMessage('tool-calls', { - calls, - }); - expect(message.type).toBe('tool-calls'); - expect(message.calls).toEqual(calls); - expect(message.id).toBeDefined(); - expect(message.timestamp).toBeTypeOf('number'); - }); -}); - -describe('createToolResultsMessage', () => { - it('creates tool results message with results array', () => { - const results: ToolResult[] = [ - { - callId: 'call_1', - name: 'get_weather', - output: { temperature: 72, conditions: 'sunny' }, - }, - { - callId: 'call_2', - name: 'get_time', - output: { time: '12:00', timezone: 'PST' }, - error: undefined, - }, - ]; - const message: ToolResultsMessage = createMessage('tool-results', { - results, - }); - expect(message.type).toBe('tool-results'); - expect(message.results).toEqual(results); - expect(message.id).toBeDefined(); - expect(message.timestamp).toBeTypeOf('number'); - }); - - it('creates tool results message with error in result', () => { - const results: ToolResult[] = [ - { - callId: 'call_3', - name: 'get_weather', - output: {}, - error: new Error('API rate limit exceeded'), - }, - ]; - const message: ToolResultsMessage = createMessage('tool-results', { - results, - }); - expect(message.results[0].error).toBeInstanceOf(Error); - expect(message.results[0].error?.message).toBe('API rate limit exceeded'); - expect(message.results[0].output).toEqual({}); - }); -}); - -describe('createSystemMessage', () => { - it('creates a system message with text', () => { - const message: SystemMessage = createMessage('system', { - text: 'Tool call completed.', - }); - - expect(message.type).toBe('system'); - expect(message.text).toBe('Tool call completed.'); - expect(message.id).toBeDefined(); - expect(message.timestamp).toBeTypeOf('number'); - }); -}); diff --git a/test/processes.test.ts b/test/processes.test.ts deleted file mode 100644 index 2501249..0000000 --- a/test/processes.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Process, ProcessContainer } from '../src/processes.js'; - -describe('Process', () => { - let process: Process; - - beforeEach(() => { - process = new Process(); - }); - - describe('constructor and initial state', () => { - it('initializes with default properties', () => { - expect(process.suspendable).toBe(true); - expect(process.state).toBeUndefined(); - expect(process.name).toBeUndefined(); - }); - - it('initializes with provided state', () => { - const initialState = { count: 0 }; - const processWithState = new Process(initialState); - expect(processWithState.state).toEqual(initialState); - }); - }); - - describe('state management', () => { - it('updates state and emits change event', () => { - let changeEmitted = false; - process.on('change', () => { - changeEmitted = true; - }); - - const newState = { value: 'test' }; - process.setState(newState); - - expect(process.state).toEqual(newState); - expect(changeEmitted).toBe(true); - }); - - it('serializes state correctly', () => { - const state = { data: 'test', count: 42 }; - process.setState(state); - expect(process.serialize()).toEqual(state); - }); - - it('returns null when no state to serialize', () => { - expect(process.serialize()).toBeNull(); - }); - - it('describes process using snapshot', () => { - const state = { status: 'running' }; - process.setState(state); - expect(process.describe()).toEqual(state); - }); - }); - - describe('lifecycle events', () => { - it('emits exit event when exiting', () => { - let exitEmitted = false; - process.on('exit', () => { - exitEmitted = true; - }); - - process.exit(); - expect(exitEmitted).toBe(true); - }); - }); - - describe('static methods', () => { - it('creates process from snapshot', () => { - const state = { restored: true }; - const restoredProcess = Process.fromSnapshot(state); - expect(restoredProcess.state).toEqual(state); - }); - }); -}); - -describe('ProcessContainer', () => { - let container: ProcessContainer; - let process: Process; - - beforeEach(() => { - process = new Process(); - process.name = 'test-process'; - container = ProcessContainer.wrap(process); - }); - - describe('constructor and wrapping', () => { - it('generates unique ID', () => { - const container1 = new ProcessContainer(); - const container2 = new ProcessContainer(); - expect(container1.id).toBeDefined(); - expect(container2.id).toBeDefined(); - expect(container1.id).not.toBe(container2.id); - }); - - it('wraps process correctly', () => { - expect(container.name).toBe('test-process'); - expect(container.status).toBe('running'); - }); - - it('accepts custom ID', () => { - const customId = 'custom-process-id'; - const containerWithId = new ProcessContainer(customId); - expect(containerWithId.id).toBe(customId); - }); - }); - - describe('event forwarding', () => { - it('forwards change events from process', () => { - let changeEmitted = false; - container.on('change', () => { - changeEmitted = true; - }); - - process.setState({ changed: true }); - expect(changeEmitted).toBe(true); - }); - - it('forwards exit events from process', () => { - let exitEmitted = false; - container.on('exit', () => { - exitEmitted = true; - }); - - process.exit(); - expect(exitEmitted).toBe(true); - }); - - it('forwards tool-result events from process', () => { - let toolResultEmitted = false; - let receivedResult: any; - - container.on('tool-result', (result) => { - toolResultEmitted = true; - receivedResult = result; - }); - - const testResult = { output: 'test-output' }; - // Use protected emit method through subclass - (process as any).emit('tool-result', testResult); - - expect(toolResultEmitted).toBe(true); - expect(receivedResult).toEqual(testResult); - }); - }); - - describe('lifecycle management', () => { - it('starts process and emits start event', () => { - let startEmitted = false; - container.on('start', () => { - startEmitted = true; - }); - - container.start(); - expect(startEmitted).toBe(true); - }); - - it('exits process and emits exit event', () => { - let exitEmitted = false; - container.on('exit', () => { - exitEmitted = true; - }); - - container.exit(); - expect(exitEmitted).toBe(true); - }); - }); - - describe('description', () => { - it('returns process description', () => { - process.setState({ status: 'active' }); - expect(container.describe()).toEqual({ status: 'active' }); - }); - }); -}); diff --git a/test/promise-process.test.ts b/test/promise-process.test.ts deleted file mode 100644 index 2129bc1..0000000 --- a/test/promise-process.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { PromiseProcess } from '../src/promise-process.js'; - -describe('PromiseProcess', () => { - describe('constructor and initial state', () => { - it('initializes with correct properties', () => { - const promiseFactory = () => Promise.resolve('test-result'); - const process = new PromiseProcess('test-promise', promiseFactory); - - expect(process.name).toBe('test-promise'); - expect(process.suspendable).toBe(false); - expect(process.state).toEqual({ status: 'in progress' }); - }); - }); - - describe('promise execution', () => { - it('resolves promise and updates state', async () => { - const expectedResult = 'test-result'; - const promiseFactory = () => Promise.resolve(expectedResult); - - const process = new PromiseProcess('test-promise', promiseFactory); - - // Wait for the promise to resolve - await new Promise((resolve) => { - process.on('tool-result', (result) => { - expect(result.output).toBe(expectedResult); - resolve(undefined); - }); - }); - - expect(process.state).toEqual({ - status: 'completed', - output: expectedResult, - }); - }); - - it('emits tool-result event with output', async () => { - const expectedResult = { data: 'complex-result', count: 42 }; - const promiseFactory = () => Promise.resolve(expectedResult); - - const process = new PromiseProcess('complex-promise', promiseFactory); - - const toolResult = await new Promise((resolve) => { - process.on('tool-result', resolve); - }); - - expect(toolResult).toEqual({ output: expectedResult }); - }); - - it('exits after completion', async () => { - const promiseFactory = () => Promise.resolve('done'); - const process = new PromiseProcess('exit-test', promiseFactory); - - const exitEmitted = await new Promise((resolve) => { - process.on('exit', () => resolve(true)); - }); - - expect(exitEmitted).toBe(true); - }); - - it.skip('handles rejected promises', async () => { - // TODO: The current PromiseProcess implementation doesn't handle promise rejections - // This test is skipped until error handling is implemented - const error = new Error('Promise failed'); - const promiseFactory = () => Promise.reject(error); - - const process = new PromiseProcess('failing-promise', promiseFactory); - - // The PromiseProcess should handle the rejection internally - // We just wait for it to complete and exit - await new Promise((resolve) => { - process.on('exit', resolve); - }); - - // The process should still complete, even if the promise was rejected - expect(process.state?.status).toBe('completed'); - }); - - it('handles slow promises', async () => { - let resolved = false; - const promiseFactory = () => - new Promise((resolve) => { - setTimeout(() => { - resolved = true; - resolve('slow-result'); - }, 50); - }); - - const process = new PromiseProcess('slow-promise', promiseFactory); - - // Initially should not be resolved - expect(resolved).toBe(false); - expect(process.state?.status).toBe('in progress'); - - // Wait for completion - await new Promise((resolve) => { - process.on('exit', resolve); - }); - - expect(resolved).toBe(true); - expect(process.state?.status).toBe('completed'); - expect((process.state as any)?.output).toBe('slow-result'); - }); - }); - - describe('integration with runtime', () => { - it('works with runtime process spawning', async () => { - // This would typically be tested in runtime.test.ts, but showing integration here - const { Runtime } = await import('../src/runtime.js'); - const runtime = new Runtime(); - - const promiseFactory = () => Promise.resolve('runtime-test'); - const process = new PromiseProcess('runtime-integration', promiseFactory); - - const container = runtime.spawn(process); - - // Wait for the process to complete - await new Promise((resolve) => { - container.on('exit', resolve); - }); - - // Should be removed from runtime after exit - expect(runtime.processes).toHaveLength(0); - }); - }); -}); diff --git a/test/runtime.test.ts b/test/runtime.test.ts deleted file mode 100644 index c87a648..0000000 --- a/test/runtime.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Runtime } from '../src/runtime.js'; -import { Process } from '../src/processes.js'; - -class TestProcess extends Process { - constructor(name: string = 'test-process') { - super(); - this.name = name; - } - - async simulate() { - // Simulate some async work - await new Promise((resolve) => setTimeout(resolve, 10)); - this.setState({ completed: true }); - (this as any).emit('tool-result', { output: 'test-result' }); - this.exit(); - } -} - -describe('Runtime', () => { - let runtime: Runtime; - - beforeEach(() => { - runtime = new Runtime(); - }); - - describe('constructor and initial state', () => { - it('initializes with empty process list', () => { - expect(runtime.processes).toEqual([]); - }); - }); - - describe('process spawning', () => { - it('spawns a process and returns container', () => { - const process = new TestProcess(); - const container = runtime.spawn(process); - - expect(container).toBeDefined(); - expect(container.id).toBeDefined(); - expect(container.name).toBe('test-process'); - expect(runtime.processes).toHaveLength(1); - expect(runtime.processes[0]).toBe(container); - }); - - it('generates unique container IDs', () => { - const process1 = new TestProcess('process-1'); - const process2 = new TestProcess('process-2'); - - const container1 = runtime.spawn(process1); - const container2 = runtime.spawn(process2); - - expect(container1.id).not.toBe(container2.id); - expect(runtime.processes).toHaveLength(2); - }); - }); - - describe('event emission', () => { - it('emits process-started event when spawning', () => { - let startedEmitted = false; - let startedContainer: any; - - runtime.on('process-started', (container) => { - startedEmitted = true; - startedContainer = container; - }); - - const process = new TestProcess(); - const container = runtime.spawn(process); - - expect(startedEmitted).toBe(true); - expect(startedContainer).toBe(container); - }); - - it('emits process-changed event when spawning', () => { - let changedEmitted = false; - let changedData: any; - - runtime.on('process-changed', (data) => { - changedEmitted = true; - changedData = data; - }); - - const process = new TestProcess(); - const container = runtime.spawn(process); - - expect(changedEmitted).toBe(true); - expect(changedData.pid).toBe(container.id); - }); - - it('emits process-changed when process state changes', () => { - const events: any[] = []; - runtime.on('process-changed', (data) => { - events.push(data); - }); - - const process = new TestProcess(); - const container = runtime.spawn(process); - - // Clear initial spawn events - events.length = 0; - - // Trigger state change - process.setState({ updated: true }); - - expect(events).toHaveLength(1); - expect(events[0].pid).toBe(container.id); - }); - - it('emits process-exited and removes from list when process exits', async () => { - let exitedEmitted = false; - let exitedData: any; - - runtime.on('process-exited', (data) => { - exitedEmitted = true; - exitedData = data; - }); - - const process = new TestProcess(); - const container = runtime.spawn(process); - const processId = container.id; - - expect(runtime.processes).toHaveLength(1); - - // Trigger exit - process.exit(); - - expect(exitedEmitted).toBe(true); - expect(exitedData.pid).toBe(processId); - expect(runtime.processes).toHaveLength(0); - }); - - it('emits tool-result events from processes', () => { - let toolResultEmitted = false; - let toolResultData: any; - - runtime.on('tool-result', (data) => { - toolResultEmitted = true; - toolResultData = data; - }); - - const process = new TestProcess(); - const container = runtime.spawn(process); - - // Trigger tool result - (process as any).emit('tool-result', { output: 'test-output' }); - - expect(toolResultEmitted).toBe(true); - expect(toolResultData.pid).toBe(container.id); - expect(toolResultData.result).toEqual({ output: 'test-output' }); - }); - }); - - describe('process management', () => { - it('finds process by ID', () => { - const process = new TestProcess(); - const container = runtime.spawn(process); - - const found = runtime.find(container.id); - expect(found).toBe(container); - }); - - it('returns undefined for non-existent process ID', () => { - const found = runtime.find('non-existent-id'); - expect(found).toBeUndefined(); - }); - }); - - describe('integration test', () => { - it('handles complete process lifecycle', async () => { - const events: string[] = []; - - runtime.on('process-started', () => events.push('started')); - runtime.on('process-changed', () => events.push('changed')); - runtime.on('process-exited', () => events.push('exited')); - runtime.on('tool-result', () => events.push('tool-result')); - - const process = new TestProcess(); - const container = runtime.spawn(process); - - expect(runtime.processes).toHaveLength(1); - - // Clear initial events - events.length = 0; - - // Run the process simulation - await process.simulate(); - - // Verify the complete lifecycle occurred - expect(events).toContain('changed'); // State change - expect(events).toContain('tool-result'); // Tool result - expect(events).toContain('exited'); // Process exit - expect(runtime.processes).toHaveLength(0); // Cleaned up - }); - }); -}); diff --git a/test/runtime/fixtures.ts b/test/runtime/fixtures.ts new file mode 100644 index 0000000..daf8fc6 --- /dev/null +++ b/test/runtime/fixtures.ts @@ -0,0 +1,27 @@ +import { Process } from '../../src'; + +export class SetMetadataProcess extends Process { + static type = 'set-metadata'; + + constructor(snapshot?: any) { + super(); + if (!snapshot) { + this.initialize(); + return; + } + this.name = snapshot.name; + this.icons = snapshot.icons; + } + + initialize() { + this.name = 'Initialized!'; + this.icons = [{ src: 'https://example.com/img.png' }]; + } + + serialize() { + return { + name: this.name, + icons: this.icons, + }; + } +} diff --git a/test/runtime/runtime.test.ts b/test/runtime/runtime.test.ts new file mode 100644 index 0000000..11bc5ee --- /dev/null +++ b/test/runtime/runtime.test.ts @@ -0,0 +1,38 @@ +import { describe, it, assert, beforeEach, expect } from 'vitest'; +import { Process } from '../../src'; +import { SetMetadataProcess } from './fixtures'; +import { ProcessContainer } from '../../src/processes/process-container'; +import { Runtime } from '../../src/runtime'; + +describe('Runtime', () => { + describe('saving and restoring', () => { + let runtime: Runtime; + let process: Process; + let container: ProcessContainer; + + beforeEach(() => { + runtime = new Runtime(); + runtime.registerProcessConstructor(SetMetadataProcess); + process = new SetMetadataProcess(); + container = runtime.spawn(SetMetadataProcess); + }); + + it('serializes the correct values', () => { + const processSnapshot = process.serialize(); + const containerSnapshot = container.serialize(); + expect(processSnapshot.name).toBe(containerSnapshot.state.name); + }); + + it('restores a single snapshot correctly', () => { + const snapshot = container.serialize(); + process = new SetMetadataProcess(snapshot.state); + expect(process.name).toBe(snapshot.state.name); + }); + + it('restores a snapshot via the runtime', () => { + const snapshot = container.serialize(); + const newContainer = runtime.restore(snapshot); + expect(newContainer.name).toBe(snapshot.state.name); + }); + }); +}); diff --git a/test/tools.test.ts b/test/tools.test.ts deleted file mode 100644 index c696a9d..0000000 --- a/test/tools.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; -import { renderTools, createTool } from '../src/tools.js'; -import { Tool } from '../src/tools.js'; - -describe('renderTools', () => { - it('should convert an array of kernel tools to a ToolSet object', () => { - const tools: Tool[] = [ - createTool({ - name: 'get_weather', - description: 'Get the weather', - parameters: z.object({ city: z.string() }), - execute: ({ city }) => `Weather in ${city}`, - }), - createTool({ - name: 'get_time', - description: 'Get the current time', - }), - ]; - - const toolset = renderTools(tools); - - expect(toolset).toHaveProperty('get_weather'); - expect(toolset).toHaveProperty('get_time'); - - const weatherTool = toolset['get_weather']; - expect(weatherTool).toBeDefined(); - expect(weatherTool!.description).toBe('Get the weather'); - expect(weatherTool!.parameters).toBeInstanceOf(z.ZodObject); - - const timeTool = toolset['get_time']; - expect(timeTool).toBeDefined(); - expect(timeTool!.description).toBe('Get the current time'); - expect(timeTool!.parameters).toBeInstanceOf(z.ZodObject); - }); - - it('should handle an empty array', () => { - const tools: Tool[] = []; - const toolset = renderTools(tools); - expect(Object.keys(toolset).length).toBe(0); - }); -}); - -describe('createTool', () => { - it('creates a tool with parameters', () => { - const tool = createTool({ - name: 'test_tool', - description: 'A test tool', - parameters: z.object({ input: z.string() }), - execute: ({ input }) => `Hello ${input}`, - }); - - expect(tool.name).toBe('test_tool'); - expect(tool.description).toBe('A test tool'); - expect(tool.parameters).toBeInstanceOf(z.ZodObject); - expect(tool.execute).toBeTypeOf('function'); - }); - - it('creates a tool without parameters', () => { - const tool = createTool({ - name: 'simple_tool', - description: 'A simple tool', - execute: () => 'Hello world', - }); - - expect(tool.name).toBe('simple_tool'); - expect(tool.description).toBe('A simple tool'); - expect(tool.execute).toBeTypeOf('function'); - }); -});