diff --git a/.gitignore b/.gitignore index a7b0e809..95a19037 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ package-lock.json /lib/ tsconfig.tsbuildinfo +tsconfig.build.tsbuildinfo *storybook.log diff --git a/bin/chat.js b/bin/chat.js index 181c9a31..65d5f0a2 100644 --- a/bin/chat.js +++ b/bin/chat.js @@ -3,17 +3,14 @@ import { tools } from './tools.js' /** @type {'text' | 'tool'} */ let outputMode = 'text' // default output mode -function systemPrompt() { - return 'You are a machine learning web application named "Hyperparam" running on a CLI terminal.' +const instructions = + 'You are a machine learning web application named "Hyperparam" running on a CLI terminal.' + '\nYou assist users with analyzing and exploring datasets, particularly in parquet format.' + ' The website and api are available at hyperparam.app.' + ' The Hyperparam CLI tool can list and explore local parquet files.' + '\nYou are on a terminal and can only output: text, emojis, terminal colors, and terminal formatting.' + ' Don\'t add additional markdown or html formatting unless requested.' + (process.stdout.isTTY ? ` The terminal width is ${process.stdout.columns} characters.` : '') -} -/** @type {Message} */ -const systemMessage = { role: 'system', content: systemPrompt() } const colors = { system: '\x1b[36m', // cyan @@ -24,12 +21,13 @@ const colors = { } /** - * @import { Message } from './types.d.ts' - * @param {Object} chatInput - * @returns {Promise} + * @import { ResponsesInput, ResponseInputItem } from './types.d.ts' + * @param {ResponsesInput} chatInput + * @returns {Promise} */ async function sendToServer(chatInput) { - const response = await fetch('https://hyperparam.app/api/functions/openai/chat', { + // Send the request to the server + const response = await fetch('https://hyperparam.app/api/functions/openai/responses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(chatInput), @@ -40,8 +38,8 @@ async function sendToServer(chatInput) { } // Process the streaming response - /** @type {Message} */ - const streamResponse = { role: 'assistant', content: '' } + /** @type {ResponseInputItem[]} */ + const incoming = [] const reader = response.body?.getReader() if (!reader) throw new Error('No response body') const decoder = new TextDecoder() @@ -66,14 +64,31 @@ async function sendToServer(chatInput) { write('\n') } outputMode = 'text' - streamResponse.content += chunk.delta + + // Append to incoming message + const last = incoming[incoming.length - 1] + if (last && 'role' in last && last.role === 'assistant' && last.id === chunk.item_id) { + // Append to existing assistant message + last.content += chunk.delta + } else { + // Create a new incoming message + incoming.push({ role: 'assistant', content: chunk.delta, id: chunk.item_id }) + } + write(chunk.delta) } else if (error) { console.error(error) throw new Error(error) - } else if (chunk.function) { - streamResponse.tool_calls ??= [] - streamResponse.tool_calls.push(chunk) + } else if (type === 'function_call') { + incoming.push(chunk) + } else if (type === 'response.output_item.done' && chunk.item.type === 'reasoning') { + /** @type {import('./types.d.ts').ReasoningItem} */ + const reasoningItem = { + type: 'reasoning', + id: chunk.item.id, + summary: chunk.item.summary, + } + incoming.push(reasoningItem) } else if (!chunk.key) { console.log('Unknown chunk', chunk) } @@ -82,53 +97,66 @@ async function sendToServer(chatInput) { } } } - return streamResponse + return incoming } /** * Send messages to the server and handle tool calls. * Will mutate the messages array! * - * @import { ToolCall, ToolHandler } from './types.d.ts' - * @param {Message[]} messages + * @import { ResponseFunctionToolCall, ToolHandler } from './types.d.ts' + * @param {ResponseInputItem[]} input * @returns {Promise} */ -async function sendMessages(messages) { +async function sendMessages(input) { + /** @type {ResponsesInput} */ const chatInput = { - model: 'gpt-4o', - messages, + model: 'gpt-5', + instructions, + input, + reasoning: { + effort: 'low', + }, tools: tools.map(tool => tool.tool), } - const response = await sendToServer(chatInput) - messages.push(response) - // handle tool results - if (response.tool_calls?.length) { - /** @type {{ toolCall: ToolCall, tool: ToolHandler, result: Promise }[]} */ - const toolResults = [] - for (const toolCall of response.tool_calls) { - const tool = tools.find(tool => tool.tool.function.name === toolCall.function.name) + const incoming = await sendToServer(chatInput) + + // handle tool calls + /** @type {{ toolCall: ResponseFunctionToolCall, tool: ToolHandler, result: Promise }[]} */ + const toolResults = [] + + // start handling tool calls + for (const message of incoming) { + if (message.type === 'function_call') { + const tool = tools.find(tool => tool.tool.name === message.name) if (tool) { - const args = JSON.parse(toolCall.function?.arguments ?? '{}') + const args = JSON.parse(message.arguments ?? '{}') const result = tool.handleToolCall(args) - toolResults.push({ toolCall, tool, result }) + toolResults.push({ toolCall: message, tool, result }) } else { - throw new Error(`Unknown tool: ${toolCall.function.name}`) + throw new Error(`Unknown tool: ${message.name}`) } } - // tool mode + } + + // tool mode + if (toolResults.length > 0) { if (outputMode === 'text') { write('\n') } outputMode = 'tool' // switch to tool output mode + + // Wait for pending tool calls and process results for (const toolResult of toolResults) { const { toolCall, tool } = toolResult + const { call_id } = toolCall try { - const content = await toolResult.result + const output = await toolResult.result // Construct function call message - const args = JSON.parse(toolCall.function?.arguments ?? '{}') + const args = JSON.parse(toolCall.arguments) const entries = Object.entries(args) - let func = toolCall.function.name + let func = toolCall.name if (entries.length === 0) { func += '()' } else { @@ -137,15 +165,22 @@ async function sendMessages(messages) { func += `(${pairs.join(', ')})` } write(colors.tool, `${tool.emoji} ${func}`, colors.normal, '\n') - messages.push({ role: 'tool', content, tool_call_id: toolCall.id }) + incoming.push({ type: 'function_call_output', output, call_id }) } catch (error) { - write(colors.error, `\nError calling tool ${toolCall.function.name}: ${error.message}`, colors.normal) - messages.push({ role: 'tool', content: `Error calling tool ${toolCall.function.name}: ${error.message}`, tool_call_id: toolCall.id }) + const message = error instanceof Error ? error.message : String(error) + const toolName = toolCall.name ?? toolCall.id + write(colors.error, `\nError calling tool ${toolName}: ${message}`, colors.normal) + incoming.push({ type: 'function_call_output', output: `Error calling tool ${toolName}: ${message}`, call_id }) } } + input.push(...incoming) + // send messages with tool results - await sendMessages(messages) + await sendMessages(input) + } else { + // no tool calls, just append incoming messages + input.push(...incoming) } } @@ -196,8 +231,8 @@ function writeWithColor() { } export function chat() { - /** @type {Message[]} */ - const messages = [systemMessage] + /** @type {ResponseInputItem[]} */ + const messages = [] process.stdin.setEncoding('utf-8') write(colors.system, 'question: ', colors.normal) diff --git a/bin/tools.js b/bin/tools.js index 5e7d82c5..3fd446f7 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -12,24 +12,22 @@ export const tools = [ emoji: '📂', tool: { type: 'function', - function: { - name: 'list_files', - description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`, - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'The path to list files from. Optional, defaults to the current directory.', - }, - filetype: { - type: 'string', - description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.', - }, - offset: { - type: 'number', - description: 'Skip offset number of files in the listing. Defaults to 0. Optional.', - }, + name: 'list_files', + description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The path to list files from. Optional, defaults to the current directory.', + }, + filetype: { + type: 'string', + description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.', + }, + offset: { + type: 'number', + description: 'Skip offset number of files in the listing. Defaults to 0. Optional.', }, }, }, @@ -61,31 +59,29 @@ export const tools = [ emoji: '📄', tool: { type: 'function', - function: { - name: 'parquet_get_rows', - description: 'Get up to 5 rows of data from a parquet file.', - parameters: { - type: 'object', - properties: { - filename: { - type: 'string', - description: 'The name of the parquet file to read.', - }, - offset: { - type: 'number', - description: 'The starting row index to fetch (0-indexed).', - }, - limit: { - type: 'number', - description: 'The number of rows to fetch. Default 5. Maximum 5.', - }, - orderBy: { - type: 'string', - description: 'The column name to sort by.', - }, + name: 'parquet_get_rows', + description: 'Get up to 5 rows of data from a parquet file.', + parameters: { + type: 'object', + properties: { + filename: { + type: 'string', + description: 'The name of the parquet file to read.', + }, + offset: { + type: 'number', + description: 'The starting row index to fetch (0-indexed).', + }, + limit: { + type: 'number', + description: 'The number of rows to fetch. Default 5. Maximum 5.', + }, + orderBy: { + type: 'string', + description: 'The column name to sort by.', }, - required: ['filename'], }, + required: ['filename'], }, }, /** @@ -133,6 +129,10 @@ function validateInteger(name, value, min, max) { return value } +/** + * @param {unknown} obj + * @param {number} [limit=1000] + */ function stringify(obj, limit = 1000) { const str = JSON.stringify(toJson(obj)) return str.length <= limit ? str : str.slice(0, limit) + '…' diff --git a/bin/types.d.ts b/bin/types.d.ts index 308dc526..e474c6db 100644 --- a/bin/types.d.ts +++ b/bin/types.d.ts @@ -1,43 +1,122 @@ -export interface ToolCall { - id: string - type: 'function' - function: { - name: string - arguments?: string +// Model Input +// Based on ResponseCreateParamsStreaming from openai client +export interface ResponsesInput { + model: string + input: ResponseInputItem[] // or string but we always use stateless messages + instructions?: string // system prompt + background?: boolean + include?: string[] + reasoning?: { + effort?: 'low' | 'medium' | 'high' + summary?: 'auto' | 'concise' | 'detailed' } + tools?: ResponseTool[] + max_output_tokens?: number + parallel_tool_calls?: boolean + previous_response_id?: string + // service_tier?: 'auto' | 'default' | 'flex' + // store?: boolean // store response for later retrieval + temperature?: number // 0..2 + text?: unknown + tool_choice?: 'auto' | 'none' | 'required' + top_p?: number // 0..1 + truncation?: 'auto' | 'disabled' + user?: string } -export type Role = 'system' | 'user' | 'assistant' | 'tool' +export type ResponseInputItem = + | EasyInputMessage + | ResponseFunctionToolCall + | FunctionCallOutput + | ReasoningItem + | WebSearchItem -export interface Message { - role: Role +// Input message types +interface EasyInputMessage { + type?: 'message' content: string - tool_calls?: ToolCall[] - tool_call_id?: string - error?: string + role: 'user' | 'assistant' | 'system' | 'developer' + id?: string // msg_123 +} +interface ResponseFunctionToolCall { + type: 'function_call' + arguments: string + call_id: string + name: string + id?: string +} + +export interface FunctionCallOutput { + type: 'function_call_output' + call_id: string // call_123 + output: string +} + +export interface ReasoningItem { + type: 'reasoning' + summary: { + type: 'summary_text' + text: string + }[] + id: string +} + +export interface WebSearchItem { + type: 'web_search_call' + id: string // ws_123 + status: 'in_progress' | 'completed' | 'failed' + action?: { + type: 'search' + } } +// Tool Handlers export interface ToolHandler { emoji: string - tool: Tool + tool: ResponseFunction // TODO: change to ResponseTool if web search is needed handleToolCall(args: Record): Promise } -interface ToolProperty { - type: string +// Tool Description +type ResponseTool = + | ResponseFunction + | WebSearchTool + +interface ResponseFunction { + type: 'function' + name: string description: string + parameters?: ToolParameters + strict?: boolean } -export interface Tool { - type: 'function' - function: { - name: string - description: string - parameters?: { - type: 'object' - properties: Record - required?: string[] - additionalProperties?: boolean - }, - strict?: boolean - } +export interface WebSearchTool { + type: 'web_search' + search_context_size?: 'low' | 'medium' | 'high' + user_location?: object +} + +// Types of tool parameters +export interface ToolParameters { + type: 'object' + properties: Record + required?: string[] + additionalProperties?: boolean +} +interface BaseToolProperty { + description?: string +} +interface StringToolProperty extends BaseToolProperty { + type: 'string' + enum?: string[] +} +interface NumberToolProperty extends BaseToolProperty { + type: 'number' +} +interface BooleanToolProperty extends BaseToolProperty { + type: 'boolean' +} +interface ArrayToolProperty extends BaseToolProperty { + type: 'array' + items: ToolProperty } +export type ToolProperty = StringToolProperty | NumberToolProperty | ArrayToolProperty | BooleanToolProperty diff --git a/package.json b/package.json index 6f81226b..97a553cf 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "!**/*.test.d.ts" ], "scripts": { - "build:types": "tsc -b", + "build:types": "tsc -b tsconfig.build.json", "build:lib": "vite build -c vite.lib.config.ts && cp src/assets/global.css lib/global.css", "build:app": "vite build", "build": "run-s build:lib build:types build:app", diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..22473454 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "lib", + "emitDeclarationOnly": true, + "declaration": true, + "composite": true, + "incremental": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 8e8280ec..eaabeedd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,10 +20,7 @@ "noUncheckedSideEffectImports": true, "noUncheckedIndexedAccess": true, - "rootDir": "src", - "outDir": "lib", - "emitDeclarationOnly": true, - "declaration": true + "noEmit": true }, - "include": ["src"] + "include": ["src", "bin"] }