diff --git a/packages/cali/src/cli.ts b/packages/cali/src/cli.ts index 840c6dd..3776efb 100755 --- a/packages/cali/src/cli.ts +++ b/packages/cali/src/cli.ts @@ -3,9 +3,10 @@ import 'dotenv/config' import { createOpenAI } from '@ai-sdk/openai' -import { confirm, outro, select, spinner, text } from '@clack/prompts' -import { CoreMessage, generateText } from 'ai' -import * as tools from 'cali-tools' +import { outro, spinner, text } from '@clack/prompts' +import { CoreAssistantMessage, CoreMessage, generateText } from 'ai' +import { tool } from 'ai' +import { toolbox, userInteractionsToolset } from 'cali-tools' import chalk from 'chalk' import dedent from 'dedent' import { retro } from 'gradient-string' @@ -14,13 +15,6 @@ import { z } from 'zod' import { reactNativePrompt } from './prompt.js' import { getApiKey } from './utils.js' -const MessageSchema = z.union([ - z.object({ type: z.literal('select'), content: z.string(), options: z.array(z.string()) }), - z.object({ type: z.literal('question'), content: z.string() }), - z.object({ type: z.literal('confirmation'), content: z.string() }), - z.object({ type: z.literal('end') }), -]) - console.clear() process.on('uncaughtException', (error) => { @@ -28,6 +22,12 @@ process.on('uncaughtException', (error) => { console.log(chalk.gray(error.stack)) }) +process.on('SIGINT', function () { + console.log('Caught interrupt signal') + + process.exit() +}) + console.log( retro(` ██████╗ █████╗ ██╗ ██╗ @@ -52,13 +52,35 @@ console.log() const AI_MODEL = process.env.AI_MODEL || 'gpt-4o' const openai = createOpenAI({ - apiKey: await getApiKey('OpenAI', 'OPENAI_API_K2EY'), + apiKey: await getApiKey('OpenAI', 'OPENAI_API_KEY'), }) -async function startSession(): Promise { +let sessionOngoing = true +let messages: CoreMessage[] = [] +const toolHand: toolbox.ToolHand = { + activeTool: null, +} + +async function startSession(messages?: CoreMessage[]): Promise { + let initialQuestion: CoreAssistantMessage + const lastMessage = messages?.at(-1) + + if (lastMessage?.role === 'assistant') { + initialQuestion = { + role: 'assistant', + content: lastMessage.content, + } + } else { + initialQuestion = { + role: 'assistant', + content: 'What do you want to do today?', + } + } + const question = await text({ - message: 'What do you want to do today?', - placeholder: 'e.g. "Build the app" or "See available simulators"', + message: initialQuestion.content as string, + placeholder: + lastMessage?.role === 'assistant' ? '' : 'e.g. "Build the app" or "See available simulators"', validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), }) @@ -68,10 +90,7 @@ async function startSession(): Promise { } return [ - { - role: 'system', - content: 'What do you want to do today?', - }, + ...(messages?.length ? [...messages] : [initialQuestion]), { role: 'user', content: question, @@ -79,46 +98,46 @@ async function startSession(): Promise { ] } -let messages = await startSession() - const s = spinner() +const finishSession = tool({ + description: 'Finish the session', + parameters: z.object({ + restarting_for_tools: z.boolean().describe('Is session ending, or just more tools are needed'), + farewell_message: z.string().describe('Your farewell message if session is ending').optional(), + }), + execute: async ({ farewell_message, restarting_for_tools }) => { + if (restarting_for_tools) { + sessionOngoing = true + return 'Starting new session with more tools' + } + + sessionOngoing = false + s.stop(farewell_message) + return 'Session finished' + }, +}) + +const gatherNewTool = toolbox.prepareToolbox(toolHand) + // eslint-disable-next-line no-constant-condition -while (true) { +while (sessionOngoing) { + messages = await startSession(messages) + console.log(toolHand) s.start(chalk.gray('Thinking...')) const response = await generateText({ model: openai(AI_MODEL), system: reactNativePrompt, - tools, + tools: { + ...userInteractionsToolset.makeInteractiveToolset(s), + gatherNewTool, + ...(toolHand.activeTool !== null ? toolbox.toolbox[toolHand.activeTool] : {}), + finishSession, + }, maxSteps: 10, messages, - onStepStart(toolCalls) { - if (toolCalls.length > 0) { - const message = `Executing: ${chalk.gray(toolCalls.map((toolCall) => toolCall.toolName).join(', '))}` - - let spinner = s.message - for (const toolCall of toolCalls) { - /** - * Certain tools call external helpers outside of our control that pipe output to our stdout. - * In such case, we stop the spinner to avoid glitches and display the output instead. - */ - if ( - [ - 'buildAndroidApp', - 'launchAndroidAppOnDevice', - 'installNpmPackage', - 'uninstallNpmPackage', - ].includes(toolCall.toolName) - ) { - spinner = s.stop - break - } - } - - spinner(message) - } - }, + toolChoice: 'auto', }) const toolCalls = response.steps.flatMap((step) => @@ -127,59 +146,12 @@ while (true) { if (toolCalls.length > 0) { s.stop(`Tools called: ${chalk.gray(toolCalls.join(', '))}`) - } else { - s.stop(chalk.gray('Done.')) - } - - for (const step of response.steps) { - if (step.text.length > 0) { - messages.push({ role: 'assistant', content: step.text }) - } - if (step.toolCalls.length > 0) { - messages.push({ role: 'assistant', content: step.toolCalls }) - } - if (step.toolResults.length > 0) { - // tbd: fix this upstream. for some reason, the tool does not include the type, - // against the spec. - for (const toolResult of step.toolResults) { - if (!toolResult.type) { - toolResult.type = 'tool-result' - } - } - messages.push({ role: 'tool', content: step.toolResults }) - } } - // tbd: handle parsing errors - const data = MessageSchema.parse(JSON.parse(response.text)) - - const answer = await (() => { - switch (data.type) { - case 'select': - return select({ - message: data.content, - options: data.options.map((option) => ({ value: option, label: option })), - }) - case 'question': - return text({ - message: data.content, - validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), - }) - case 'confirmation': { - return confirm({ message: data.content }).then((answer) => { - return answer ? 'yes' : 'no' - }) - } - } - })() - - if (typeof answer !== 'string') { - messages = await startSession() - continue + if (!sessionOngoing) { + s.stop(chalk.gray('Done.')) + } else { + s.stop() + messages.push({ role: 'assistant', content: response.text }) } - - messages.push({ - role: 'user', - content: answer as string, - }) } diff --git a/packages/cali/src/prompt.ts b/packages/cali/src/prompt.ts index 989f386..bb6ffa5 100644 --- a/packages/cali/src/prompt.ts +++ b/packages/cali/src/prompt.ts @@ -2,107 +2,35 @@ import dedent from 'dedent' export const reactNativePrompt = dedent` ROLE: - You are a React Native developer tasked with building and shipping a React Native app. - Use tools to gather information about the project. + You are a React Native developer tasked with building and shipping a React Native app + Use tools to gather information about the project + Use tools to ask questions, present selection options and get confirmations - TOOL PARAMETERS: - - If tools require parameters, ask the user to provide them explicitly. - - If you can get required parameters by running other tools beforehand, you must run the tools instead of asking. - - TOOL RETURN VALUES: - - If tool returns an array, always ask user to select one of the options. - - Never decide for the user. + TOOLS USAGE: + - You have tools dedicated for interaction with user + - If tools require parameters, ask the user to provide them explicitly + - If you can get required parameters by running other tools beforehand, you must run the tools instead of asking + - If tool returns an array, always ask user to select one of the options + - Never decide for the user WORKFLOW RULES: - - You do not know what platforms are available. You must run a tool to list available platforms. - - Ask one clear and concise question at a time. - - If you need more information, ask a follow-up question. - - Never build or run for multiple platforms simultaneously. - - If user selects "Debug" mode, always start Metro bundler using "startMetro" tool. + - You do not know what platforms are available. You must run a tool to list available platforms + - Ask one clear and concise question at a time + - If you need more information, ask a follow-up question + - Never build or run for multiple platforms simultaneously + - If user selects "Debug" mode, always start Metro bundler using "startMetro" tool ERROR HANDLING: - - If a tool call returns an error, you must explain the error to the user and ask user if they want to try again: - { - "type": "confirmation", - "content": "" - } - - If you have tools to fix the error, ask user to select one of them: - { - "type": "select", - "content": "", - "options": ["", "", ""] - } + - If a tool call returns an error, you must explain the error to the user and ask user if they want to try again + - If you have tools to fix the error, ask user to select one of them - MANUAL RESOLUTION: - - If you do not have tools to fix the error, you must ask a Yes/No question with manual steps as content: - { - "type": "confirmation", - "content": "" - } - - - If user confirms, you must re-run the same tool. - - Never ask user to perform the action manually. Instead, ask user to fix the error, so you can run the tool again. - - If single tool fails more than 3 times, you must end the session. - - RESPONSE FORMAT: - - Your response must be a valid JSON object. - - Your response must not contain any other text. - - Your response must start with { and end with }. - - RESPONSE TYPES: - - If the question is a question that involves choosing from a list of options, you must return: - { - "type": "select", - "content": "", - "options": ["", "", ""] - } - - If the question is a free-form question, you must return: - { - "type": "question", - "content": "" - } - - If the question is a Yes/No or it is a confirmation question, you must return: - { - "type": "confirmation", - "content": "" - } - - When you finish processing user task, you must answer with: - { - "type": "end", - } - - EXAMPLES: - - - Here are some tasks you can perform: - - 1. Option 1 - 2. Option 2 - - - { - "type": "select", - "content": "Here are some tasks you can perform:", - "options": ["Option 1", "Option 2"] - } - - - - - Please provide X so I can do Y. - - - { - "type": "question", - "content": "Please provide X so I can do Y." - } - - - - - Please provide path to ADB executable. - - Do not ask user to provide path to ADB executable. - Run "getAdbPath" tool and use its result. - + MANUAL RESOLUTION: + - If you do not have tools to fix the error, you must ask a Yes/No question with manual steps as content + - If user confirms, you must re-run the same tool + - Never ask user to perform the action manually. Instead, ask user to fix the error, so you can run the tool again + - If single tool fails more than 3 times, you must end the session + + RESPONSE RULES: + - If you decide not to use any tool, and the session is not finished, as what else can you help with. + - Treat your response as either a farewell, or start of new session ` diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 183d3da..0add067 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,6 +1,8 @@ -export * from './android.js' -export * from './apple.js' -export * from './fs.js' -export * from './git.js' -export * from './npm.js' -export * from './react-native.js' +export * as androidToolset from './android.js' +export * as appleToolset from './apple.js' +export * as fileSystemToolset from './fs.js' +export * as gitToolset from './git.js' +export * as npmToolset from './npm.js' +export * as reactNativeToolset from './react-native.js' +export * as toolbox from './toolbox.js' +export * as userInteractionsToolset from './user-interaction.js' diff --git a/packages/tools/src/toolbox.ts b/packages/tools/src/toolbox.ts new file mode 100644 index 0000000..4a9c12c --- /dev/null +++ b/packages/tools/src/toolbox.ts @@ -0,0 +1,38 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import * as androidToolset from './android.js' +import * as appleToolset from './apple.js' +import * as fileSystemToolset from './fs.js' +import * as gitToolset from './git.js' +import * as npmToolset from './npm.js' +import * as reactNativeToolset from './react-native.js' + +const toolsets = ['android', 'apple', 'file_system', 'git', 'npm', 'react_native'] as const + +export const toolbox = { + android: androidToolset, + apple: appleToolset, + file_system: fileSystemToolset, + git: gitToolset, + npm: npmToolset, + react_native: reactNativeToolset, +} + +export type ToolHand = { + activeTool: (typeof toolsets)[number] | null +} + +export const prepareToolbox = (toolhand: ToolHand) => + tool({ + description: 'Gather new toolset, after using that function you need to start new session.', + parameters: z.object({ + tool_to_gather: z.enum(toolsets).describe('Tool you need for current job'), + }), + execute: async ({ tool_to_gather }) => { + toolhand.activeTool = tool_to_gather + // gatheringTools = true + + return 'Toolset gathered, start a new session' + }, + }) diff --git a/packages/tools/src/user-interaction.ts b/packages/tools/src/user-interaction.ts new file mode 100644 index 0000000..4e311d3 --- /dev/null +++ b/packages/tools/src/user-interaction.ts @@ -0,0 +1,88 @@ +import { confirm, select, text } from '@clack/prompts' +import { tool } from 'ai' +import { z } from 'zod' + +type Spinner = { + start: (msg?: string) => void + stop: (msg?: string, code?: number) => void + message: (msg?: string) => void +} + +export const makeInteractiveToolset = (s: Spinner) => { + return { + askQuestion: askQuestion(s), + getConfirmation: getConfirmation(s), + presentOptions: presentOptions(s), + concludeTask, + } +} + +export const askQuestion = (s: Spinner) => + tool({ + description: 'Interact with user to as him a question', + parameters: z.object({ + question: z.string().describe('What do you want to ask'), + }), + execute: async ({ question }) => { + s.stop('A question:') + const response = await text({ + message: question, + validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), + }) + s.start() + return response + }, + }) + +export const getConfirmation = (s: Spinner) => + tool({ + description: 'Interact with user to get a confirmation before action.', + parameters: z.object({ + confirmation: z.string().describe('What do you want to confirm with user'), + }), + execute: async ({ confirmation }) => { + s.stop('Please confirm:') + const response = await confirm({ message: confirmation }).then((answer) => { + return answer ? 'yes' : 'no' + }) + s.start() + return response + }, + }) + +export const presentOptions = (s: Spinner) => + tool({ + description: 'Interact with user to present him with options selection', + parameters: z.object({ + description: z.string().describe('Describe the selection for user'), + options: z.array(z.string()).describe('Array with options for user'), + }), + execute: async ({ description, options }) => { + s.stop('Select an option:') + const response = await select({ + message: description, + options: options.map((option) => ({ value: option, label: option })), + }) + s.start() + return response + }, + }) + +export const concludeTask = tool({ + description: 'Inform user about a finished task', + parameters: z.object({ + conclusion: z.string().describe('Summarise your work on the task'), + }), + execute: async ({ conclusion }) => { + console.log('Running conclude') + text({ + message: conclusion, + }) + + const response = await confirm({ message: 'Do you want to continue?' }) + + return response + ? 'Conclusion presented to user, but he wants to continue conversation' + : 'Conclusion presented, user wants to end the session' + }, +})