Skip to content

Commit 7fb7a4b

Browse files
authored
Chat: use responses-style tool calls (#320)
1 parent e42b6e0 commit 7fb7a4b

File tree

7 files changed

+242
-117
lines changed

7 files changed

+242
-117
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ package-lock.json
3232

3333
/lib/
3434
tsconfig.tsbuildinfo
35+
tsconfig.build.tsbuildinfo
3536
*storybook.log

bin/chat.js

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,14 @@ import { tools } from './tools.js'
33
/** @type {'text' | 'tool'} */
44
let outputMode = 'text' // default output mode
55

6-
function systemPrompt() {
7-
return 'You are a machine learning web application named "Hyperparam" running on a CLI terminal.'
6+
const instructions =
7+
'You are a machine learning web application named "Hyperparam" running on a CLI terminal.'
88
+ '\nYou assist users with analyzing and exploring datasets, particularly in parquet format.'
99
+ ' The website and api are available at hyperparam.app.'
1010
+ ' The Hyperparam CLI tool can list and explore local parquet files.'
1111
+ '\nYou are on a terminal and can only output: text, emojis, terminal colors, and terminal formatting.'
1212
+ ' Don\'t add additional markdown or html formatting unless requested.'
1313
+ (process.stdout.isTTY ? ` The terminal width is ${process.stdout.columns} characters.` : '')
14-
}
15-
/** @type {Message} */
16-
const systemMessage = { role: 'system', content: systemPrompt() }
1714

1815
const colors = {
1916
system: '\x1b[36m', // cyan
@@ -24,12 +21,13 @@ const colors = {
2421
}
2522

2623
/**
27-
* @import { Message } from './types.d.ts'
28-
* @param {Object} chatInput
29-
* @returns {Promise<Message>}
24+
* @import { ResponsesInput, ResponseInputItem } from './types.d.ts'
25+
* @param {ResponsesInput} chatInput
26+
* @returns {Promise<ResponseInputItem[]>}
3027
*/
3128
async function sendToServer(chatInput) {
32-
const response = await fetch('https://hyperparam.app/api/functions/openai/chat', {
29+
// Send the request to the server
30+
const response = await fetch('https://hyperparam.app/api/functions/openai/responses', {
3331
method: 'POST',
3432
headers: { 'Content-Type': 'application/json' },
3533
body: JSON.stringify(chatInput),
@@ -40,8 +38,8 @@ async function sendToServer(chatInput) {
4038
}
4139

4240
// Process the streaming response
43-
/** @type {Message} */
44-
const streamResponse = { role: 'assistant', content: '' }
41+
/** @type {ResponseInputItem[]} */
42+
const incoming = []
4543
const reader = response.body?.getReader()
4644
if (!reader) throw new Error('No response body')
4745
const decoder = new TextDecoder()
@@ -66,14 +64,31 @@ async function sendToServer(chatInput) {
6664
write('\n')
6765
}
6866
outputMode = 'text'
69-
streamResponse.content += chunk.delta
67+
68+
// Append to incoming message
69+
const last = incoming[incoming.length - 1]
70+
if (last && 'role' in last && last.role === 'assistant' && last.id === chunk.item_id) {
71+
// Append to existing assistant message
72+
last.content += chunk.delta
73+
} else {
74+
// Create a new incoming message
75+
incoming.push({ role: 'assistant', content: chunk.delta, id: chunk.item_id })
76+
}
77+
7078
write(chunk.delta)
7179
} else if (error) {
7280
console.error(error)
7381
throw new Error(error)
74-
} else if (chunk.function) {
75-
streamResponse.tool_calls ??= []
76-
streamResponse.tool_calls.push(chunk)
82+
} else if (type === 'function_call') {
83+
incoming.push(chunk)
84+
} else if (type === 'response.output_item.done' && chunk.item.type === 'reasoning') {
85+
/** @type {import('./types.d.ts').ReasoningItem} */
86+
const reasoningItem = {
87+
type: 'reasoning',
88+
id: chunk.item.id,
89+
summary: chunk.item.summary,
90+
}
91+
incoming.push(reasoningItem)
7792
} else if (!chunk.key) {
7893
console.log('Unknown chunk', chunk)
7994
}
@@ -82,53 +97,66 @@ async function sendToServer(chatInput) {
8297
}
8398
}
8499
}
85-
return streamResponse
100+
return incoming
86101
}
87102

88103
/**
89104
* Send messages to the server and handle tool calls.
90105
* Will mutate the messages array!
91106
*
92-
* @import { ToolCall, ToolHandler } from './types.d.ts'
93-
* @param {Message[]} messages
107+
* @import { ResponseFunctionToolCall, ToolHandler } from './types.d.ts'
108+
* @param {ResponseInputItem[]} input
94109
* @returns {Promise<void>}
95110
*/
96-
async function sendMessages(messages) {
111+
async function sendMessages(input) {
112+
/** @type {ResponsesInput} */
97113
const chatInput = {
98-
model: 'gpt-4o',
99-
messages,
114+
model: 'gpt-5',
115+
instructions,
116+
input,
117+
reasoning: {
118+
effort: 'low',
119+
},
100120
tools: tools.map(tool => tool.tool),
101121
}
102-
const response = await sendToServer(chatInput)
103-
messages.push(response)
104-
// handle tool results
105-
if (response.tool_calls?.length) {
106-
/** @type {{ toolCall: ToolCall, tool: ToolHandler, result: Promise<string> }[]} */
107-
const toolResults = []
108-
for (const toolCall of response.tool_calls) {
109-
const tool = tools.find(tool => tool.tool.function.name === toolCall.function.name)
122+
const incoming = await sendToServer(chatInput)
123+
124+
// handle tool calls
125+
/** @type {{ toolCall: ResponseFunctionToolCall, tool: ToolHandler, result: Promise<string> }[]} */
126+
const toolResults = []
127+
128+
// start handling tool calls
129+
for (const message of incoming) {
130+
if (message.type === 'function_call') {
131+
const tool = tools.find(tool => tool.tool.name === message.name)
110132
if (tool) {
111-
const args = JSON.parse(toolCall.function?.arguments ?? '{}')
133+
const args = JSON.parse(message.arguments ?? '{}')
112134
const result = tool.handleToolCall(args)
113-
toolResults.push({ toolCall, tool, result })
135+
toolResults.push({ toolCall: message, tool, result })
114136
} else {
115-
throw new Error(`Unknown tool: ${toolCall.function.name}`)
137+
throw new Error(`Unknown tool: ${message.name}`)
116138
}
117139
}
118-
// tool mode
140+
}
141+
142+
// tool mode
143+
if (toolResults.length > 0) {
119144
if (outputMode === 'text') {
120145
write('\n')
121146
}
122147
outputMode = 'tool' // switch to tool output mode
148+
149+
// Wait for pending tool calls and process results
123150
for (const toolResult of toolResults) {
124151
const { toolCall, tool } = toolResult
152+
const { call_id } = toolCall
125153
try {
126-
const content = await toolResult.result
154+
const output = await toolResult.result
127155

128156
// Construct function call message
129-
const args = JSON.parse(toolCall.function?.arguments ?? '{}')
157+
const args = JSON.parse(toolCall.arguments)
130158
const entries = Object.entries(args)
131-
let func = toolCall.function.name
159+
let func = toolCall.name
132160
if (entries.length === 0) {
133161
func += '()'
134162
} else {
@@ -137,15 +165,22 @@ async function sendMessages(messages) {
137165
func += `(${pairs.join(', ')})`
138166
}
139167
write(colors.tool, `${tool.emoji} ${func}`, colors.normal, '\n')
140-
messages.push({ role: 'tool', content, tool_call_id: toolCall.id })
168+
incoming.push({ type: 'function_call_output', output, call_id })
141169
} catch (error) {
142-
write(colors.error, `\nError calling tool ${toolCall.function.name}: ${error.message}`, colors.normal)
143-
messages.push({ role: 'tool', content: `Error calling tool ${toolCall.function.name}: ${error.message}`, tool_call_id: toolCall.id })
170+
const message = error instanceof Error ? error.message : String(error)
171+
const toolName = toolCall.name ?? toolCall.id
172+
write(colors.error, `\nError calling tool ${toolName}: ${message}`, colors.normal)
173+
incoming.push({ type: 'function_call_output', output: `Error calling tool ${toolName}: ${message}`, call_id })
144174
}
145175
}
146176

177+
input.push(...incoming)
178+
147179
// send messages with tool results
148-
await sendMessages(messages)
180+
await sendMessages(input)
181+
} else {
182+
// no tool calls, just append incoming messages
183+
input.push(...incoming)
149184
}
150185
}
151186

@@ -196,8 +231,8 @@ function writeWithColor() {
196231
}
197232

198233
export function chat() {
199-
/** @type {Message[]} */
200-
const messages = [systemMessage]
234+
/** @type {ResponseInputItem[]} */
235+
const messages = []
201236
process.stdin.setEncoding('utf-8')
202237

203238
write(colors.system, 'question: ', colors.normal)

bin/tools.js

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,22 @@ export const tools = [
1212
emoji: '📂',
1313
tool: {
1414
type: 'function',
15-
function: {
16-
name: 'list_files',
17-
description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`,
18-
parameters: {
19-
type: 'object',
20-
properties: {
21-
path: {
22-
type: 'string',
23-
description: 'The path to list files from. Optional, defaults to the current directory.',
24-
},
25-
filetype: {
26-
type: 'string',
27-
description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.',
28-
},
29-
offset: {
30-
type: 'number',
31-
description: 'Skip offset number of files in the listing. Defaults to 0. Optional.',
32-
},
15+
name: 'list_files',
16+
description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`,
17+
parameters: {
18+
type: 'object',
19+
properties: {
20+
path: {
21+
type: 'string',
22+
description: 'The path to list files from. Optional, defaults to the current directory.',
23+
},
24+
filetype: {
25+
type: 'string',
26+
description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.',
27+
},
28+
offset: {
29+
type: 'number',
30+
description: 'Skip offset number of files in the listing. Defaults to 0. Optional.',
3331
},
3432
},
3533
},
@@ -61,31 +59,29 @@ export const tools = [
6159
emoji: '📄',
6260
tool: {
6361
type: 'function',
64-
function: {
65-
name: 'parquet_get_rows',
66-
description: 'Get up to 5 rows of data from a parquet file.',
67-
parameters: {
68-
type: 'object',
69-
properties: {
70-
filename: {
71-
type: 'string',
72-
description: 'The name of the parquet file to read.',
73-
},
74-
offset: {
75-
type: 'number',
76-
description: 'The starting row index to fetch (0-indexed).',
77-
},
78-
limit: {
79-
type: 'number',
80-
description: 'The number of rows to fetch. Default 5. Maximum 5.',
81-
},
82-
orderBy: {
83-
type: 'string',
84-
description: 'The column name to sort by.',
85-
},
62+
name: 'parquet_get_rows',
63+
description: 'Get up to 5 rows of data from a parquet file.',
64+
parameters: {
65+
type: 'object',
66+
properties: {
67+
filename: {
68+
type: 'string',
69+
description: 'The name of the parquet file to read.',
70+
},
71+
offset: {
72+
type: 'number',
73+
description: 'The starting row index to fetch (0-indexed).',
74+
},
75+
limit: {
76+
type: 'number',
77+
description: 'The number of rows to fetch. Default 5. Maximum 5.',
78+
},
79+
orderBy: {
80+
type: 'string',
81+
description: 'The column name to sort by.',
8682
},
87-
required: ['filename'],
8883
},
84+
required: ['filename'],
8985
},
9086
},
9187
/**
@@ -133,6 +129,10 @@ function validateInteger(name, value, min, max) {
133129
return value
134130
}
135131

132+
/**
133+
* @param {unknown} obj
134+
* @param {number} [limit=1000]
135+
*/
136136
function stringify(obj, limit = 1000) {
137137
const str = JSON.stringify(toJson(obj))
138138
return str.length <= limit ? str : str.slice(0, limit) + '…'

0 commit comments

Comments
 (0)