Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 72 additions & 100 deletions packages/cali/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -14,20 +15,19 @@ 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) => {
console.error(chalk.red(error.message))
console.log(chalk.gray(error.stack))
})

process.on('SIGINT', function () {
console.log('Caught interrupt signal')

process.exit()
})

console.log(
retro(`
██████╗ █████╗ ██╗ ██╗
Expand All @@ -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<CoreMessage[]> {
let sessionOngoing = true
let messages: CoreMessage[] = []
const toolHand: toolbox.ToolHand = {
activeTool: null,
}

async function startSession(messages?: CoreMessage[]): Promise<CoreMessage[]> {
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.'),
})

Expand All @@ -68,57 +90,54 @@ async function startSession(): Promise<CoreMessage[]> {
}

return [
{
role: 'system',
content: 'What do you want to do today?',
},
...(messages?.length ? [...messages] : [initialQuestion]),
{
role: 'user',
content: question,
},
]
}

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) =>
Expand All @@ -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,
})
}
122 changes: 25 additions & 97 deletions packages/cali/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: explicitly refer to the tool names to use for those operations


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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what is rationale behind merging parameters and return values into one group?

- 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": "<error explanation and retry question>"
}
- If you have tools to fix the error, ask user to select one of them:
{
"type": "select",
"content": "<error explanation and tool selection question>",
"options": ["<option1>", "<option2>", "<option3>"]
}
- 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": "<error explanation and manual steps>"
}

- 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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these descriptions were in my opinion more informative than current tool descriptions, let's update tool descriptions to be similar to those response type requirements

{
"type": "select",
"content": "<question>",
"options": ["<option1>", "<option2>", "<option3>"]
}
- If the question is a free-form question, you must return:
{
"type": "question",
"content": "<question>"
}
- If the question is a Yes/No or it is a confirmation question, you must return:
{
"type": "confirmation",
"content": "<question>"
}
- When you finish processing user task, you must answer with:
{
"type": "end",
}

EXAMPLES:
<example>
<bad>
Here are some tasks you can perform:

1. Option 1
2. Option 2
</bad>
<good>
{
"type": "select",
"content": "Here are some tasks you can perform:",
"options": ["Option 1", "Option 2"]
}
</good>
</example>
<example>
<bad>
Please provide X so I can do Y.
</bad>
<good>
{
"type": "question",
"content": "Please provide X so I can do Y."
}
</good>
</example>
<example>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should not remove examples, particularly that instruct the LLM to call tool for something it has access to.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toola calls are implemented by provider. Running tool calls is a different flow than giving a response.

<bad>
Please provide path to ADB executable.
</bad>
Do not ask user to provide path to ADB executable.
Run "getAdbPath" tool and use its result.
</example>
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
`
14 changes: 8 additions & 6 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading