CookbookTool loop in TypeScript

Tool loop in TypeScript

The same library assistant as Tool loop in Python, typed, with the openai package pointed at Syntic. Run it with npm install openai tsx and npx tsx loop.ts.

// loop.ts
import OpenAI from 'openai'
import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions'
 
const client = new OpenAI({ baseURL: 'https://app.syntic.ai/v1', apiKey: process.env.SYNTIC_API_KEY })
 
// --- Your functions ---------------------------------------------------------
type Book = { title: string; copies: number; onLoan: number; dueBack: string | null }
const LIBRARY: Record<string, Book> = {
  '978-0141439518': { title: 'Pride and Prejudice', copies: 3, onLoan: 3, dueBack: '2026-10-19' },
  '978-0007119318': { title: 'The Hobbit', copies: 2, onLoan: 1, dueBack: null },
}
 
const functions: Record<string, (args: any) => unknown> = {
  find_book: ({ query }: { query: string }) => ({
    results: Object.entries(LIBRARY)
      .filter(([, b]) => b.title.toLowerCase().includes(String(query).toLowerCase()))
      .map(([isbn, b]) => ({ isbn, title: b.title })),
  }),
  availability: ({ isbn }: { isbn: string }) => {
    const b = LIBRARY[isbn]
    if (!b) return { error: `no book with ISBN ${isbn}` }
    const free = b.copies - b.onLoan
    return { available: free, next_due_back: free ? null : b.dueBack }
  },
}
 
// --- How they are described to the model ----------------------------------
const tools: ChatCompletionTool[] = [
  {
    type: 'function',
    function: {
      name: 'find_book',
      description: 'Search the catalogue by title words. Returns ISBNs.',
      parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
    },
  },
  {
    type: 'function',
    function: {
      name: 'availability',
      description: 'Copies available now for an ISBN, and when the next one is due back.',
      parameters: { type: 'object', properties: { isbn: { type: 'string' } }, required: ['isbn'] },
    },
  },
]
 
// --- The loop ----------------------------------------------------------------
export async function run(question: string, maxRounds = 8): Promise<string> {
  const messages: ChatCompletionMessageParam[] = [
    { role: 'system', content: `You help members of a town library. Today is ${new Date().toISOString().slice(0, 10)}. Use the tools; never guess availability.` },
    { role: 'user', content: question },
  ]
  for (let round = 0; round < maxRounds; round++) {
    const reply = await client.chat.completions.create({
      model: 'Amara S1.2', stream: false, max_tokens: 800, tools, messages,
    })
    const msg = reply.choices[0].message
    if (!msg.tool_calls?.length) return msg.content ?? ''
    messages.push(msg) // the assistant turn, unchanged
    for (const call of msg.tool_calls) {
      let result: unknown
      try {
        const fn = functions[call.function.name]
        if (!fn) throw new Error(`unknown tool ${call.function.name}`)
        result = fn(JSON.parse(call.function.arguments || '{}'))
      } catch (err) {
        result = { error: (err as Error).message }
      }
      messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
    }
  }
  return "Sorry, I couldn't finish that request."
}
 
console.log(await run('Can I borrow Pride and Prejudice today? If not, when?'))

Notes

  • call.function.arguments is a JSON string. Parse it inside try, because it can be invalid.
  • Treat call.id as an opaque string; its format is not fixed.
  • Pass the same tools array on every round so the prompt prefix stays identical and cacheable.
  • Depending on your openai package version, the tool-call type may be a union that includes non-function calls; narrow it with call.type === 'function' if the compiler asks.
Was this page helpful?