From 39048a9e2ec39e0920100e4da5a336a448fb7d7f Mon Sep 17 00:00:00 2001 From: Ntwari Bruce <107446340+ntwari-bruce@users.noreply.github.com> Date: Thu, 27 Mar 2025 13:31:24 -0500 Subject: [PATCH] dev: ai command tool use / function calling (#1194) * Enhanced ai command to perfom other commands * Enhance AI Command in Puter's shell * Enahanced ai command to use tooling/function calling * Fixed circular dependency and added list function to Builtincommand * Fixed circular dependency and system prompt --- package.json | 1 + src/phoenix/src/puter-shell/coreutils/ai.js | 182 ++++++++++++------ .../puter-shell/plugins/ChatHistoryPlugin.js | 6 - .../providers/BuiltinCommandProvider.js | 8 + .../providers/CompositeCommandProvider.js | 11 ++ 5 files changed, 145 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index 6ccee3915..817be9657 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ ] }, "dependencies": { + "@heyputer/putility": "^1.0.2", "dedent": "^1.5.3", "javascript-time-ago": "^2.5.11", "json-colorizer": "^3.0.1", diff --git a/src/phoenix/src/puter-shell/coreutils/ai.js b/src/phoenix/src/puter-shell/coreutils/ai.js index e0537eca1..9c702ff13 100644 --- a/src/phoenix/src/puter-shell/coreutils/ai.js +++ b/src/phoenix/src/puter-shell/coreutils/ai.js @@ -18,6 +18,7 @@ */ import { Exit } from './coreutil_lib/exit.js'; + export default { name: 'ai', usage: 'ai PROMPT', @@ -41,7 +42,46 @@ export default { await ctx.externs.err.write('ai: prompt must be wrapped in quotes\n'); throw new Exit(1); } + + const tools = []; + + const commands = await ctx.externs.commandProvider.list(); + + for (const command of commands) { + if (command.args && command.args.options) { + const parameters = { + type: "object", + properties: {}, + required: [] + }; + for (const [optName, opt] of Object.entries(command.args.options)) { + parameters.properties[optName] = { + type: opt.type === 'boolean' ? 'boolean' : 'string', + description: opt.description, + default: opt.default + }; + } + + if (command.args.allowPositionals) { + parameters.properties.path = { + type: "string", + description: "Path or name to operate on" + }; + parameters.required.push("path"); + } + + tools.push({ + type: "function", + function: { + name: command.name, + description: command.description, + parameters: parameters, + strict: true + } + }); + } + } const { drivers } = ctx.platform; const { chatHistory } = ctx.plugins; @@ -54,21 +94,15 @@ export default { ...chatHistory.get_messages(), { role: 'system', - content: `You are a helpful AI assistant that helps users with shell commands. - When a user asks to perform an action: - 1. If the action requires a command, wrap ONLY the command between %%% markers - 2. Keep the command simple and on a single line - 3. Do not ask for confirmation - Example: - User: "create a directory named test" - You: "Creating directory 'test' - %%%mkdir test%%%"` + content: `You are a helpful AI assistant that helps users with shell commands. Use the provided tools to execute commands. ` }, { role: 'user', content: prompt, } ], + tools: tools, + stream: true }; console.log('THESE ARE THE MESSAGES', a_args.messages); @@ -79,63 +113,97 @@ export default { args: a_args, }); - const resobj = JSON.parse(await result.text(), null, 2); - - if ( resobj.success !== true ) { - await ctx.externs.err.write('request failed\n'); - await ctx.externs.err.write(resobj); - return; - } - - const message = resobj?.result?.message?.content; - - if ( ! message ) { - await ctx.externs.err.write('message not found in response\n'); - await ctx.externs.err.write(result); - return; - } - + const responseText = await result.text(); + const lines = responseText.split('\n').filter(line => line.trim()); - chatHistory.add_message(resobj?.result?.message); - - const commandMatch = message.match(/%%%(.*?)%%%/); - - if (commandMatch) { - const commandToExecute = commandMatch[1].trim(); - const cleanMessage = message.replace(/%%%(.*?)%%%/, ''); - - await ctx.externs.out.write(cleanMessage + '\n'); - - await ctx.externs.out.write(`Execute command: '${commandToExecute}' (y/n): `); - + let fullMessage = ''; + + for (const line of lines) { try { - let line, done; - const next_line = async () => { - ({ value: line, done } = await ctx.externs.in_.read()); + const chunk = JSON.parse(line); + + if (chunk.type === 'text') { + fullMessage += chunk.text; + await ctx.externs.out.write(chunk.text); } + + else if (chunk.type === 'tool_use' && chunk.name) { + const args = chunk.input; + const command = await ctx.externs.commandProvider.lookup(chunk.name); - await next_line(); + if (command) { + let cmdString = chunk.name; + + if (command.args && command.args.options) { + for (const [optName, value] of Object.entries(args)) { + if (optName !== 'path' && value === true) { + cmdString += ` --${optName}`; + } + } + } - const inputString = new TextDecoder().decode(line); - const response = (inputString ?? '').trim().toLowerCase(); + if (args.path) { + cmdString += ` ${args.path}`; + } - console.log('processed response', {response}); + await ctx.externs.out.write(`\nExecuting: ${cmdString}\n`); + await ctx.externs.out.write('Proceed? (y/n): '); - if (!response.startsWith('y')) { - await ctx.externs.out.write('\nCommand execution cancelled\n'); - return; + let { value: line } = await ctx.externs.in_.read(); + const inputString = new TextDecoder().decode(line); + const response = inputString.trim().toLowerCase(); + + await ctx.externs.out.write('\n'); + + if (response.startsWith('y')) { + try { + await ctx.shell.runPipeline(cmdString); + + await drivers.call({ + interface: 'puter-chat-completion', + method: 'complete', + args: { + messages: [ + ...chatHistory.get_messages(), + { + role: "tool", + tool_call_id: chunk.id, + content: `Command executed successfully: ${cmdString}` + } + ] + } + }); + + fullMessage += `Command executed successfully: ${cmdString}`; + } catch(error) { + await ctx.externs.err.write(`Error executing command: ${error.message}\n`); + fullMessage += `Failed to execute command: ${error.message}`; + return; + } + } else { + await ctx.externs.out.write('Operation cancelled.\n'); + fullMessage += 'Operation cancelled'; + } + } } - - await ctx.externs.out.write('\n'); - await ctx.shell.runPipeline(commandToExecute); - await ctx.externs.out.write(`Command executed: ${commandToExecute}\n`); } catch (error) { - await ctx.externs.err.write(`Error executing command: ${error.message}\n`); - return; + await ctx.externs.err.write(`Error parsing chunk: ${error.message}\n`); + throw new Exit(1); } - } else { - await ctx.externs.out.write(message + '\n'); } - } -} + await ctx.externs.out.write('\n'); + + if (!fullMessage) { + await ctx.externs.err.write('message not found in response\n'); + return; + } + + chatHistory.add_message({ + role: 'assistant', + content: fullMessage + }); + + } + +} \ No newline at end of file diff --git a/src/phoenix/src/puter-shell/plugins/ChatHistoryPlugin.js b/src/phoenix/src/puter-shell/plugins/ChatHistoryPlugin.js index cac9ee05d..a3ae3edfd 100644 --- a/src/phoenix/src/puter-shell/plugins/ChatHistoryPlugin.js +++ b/src/phoenix/src/puter-shell/plugins/ChatHistoryPlugin.js @@ -23,12 +23,6 @@ export const CreateChatHistoryPlugin = ctx => { content: 'You are running inside the Puter terminal via the `ai` command. Refer to yourself as Puter Terminal AI.', }, - { - role: 'system', - content: - // note: this really doesn't work at all; GPT is effectively incapable of following this instruction. - 'You can provide commands to the user by prefixing a line in your response with %%%. The user will then be able to run the command by accepting confirmation.', - }, { role: 'system', content: diff --git a/src/phoenix/src/puter-shell/providers/BuiltinCommandProvider.js b/src/phoenix/src/puter-shell/providers/BuiltinCommandProvider.js index 5c8287d83..088cee773 100644 --- a/src/phoenix/src/puter-shell/providers/BuiltinCommandProvider.js +++ b/src/phoenix/src/puter-shell/providers/BuiltinCommandProvider.js @@ -36,4 +36,12 @@ export class BuiltinCommandProvider { return Object.keys(builtins) .filter(commandName => commandName.startsWith(query)); } + + async list() { + return Object.entries(builtins).map(([name, command]) => ({ + name, + ...command + })); + } + } diff --git a/src/phoenix/src/puter-shell/providers/CompositeCommandProvider.js b/src/phoenix/src/puter-shell/providers/CompositeCommandProvider.js index d5aff29b6..8cc1a03e3 100644 --- a/src/phoenix/src/puter-shell/providers/CompositeCommandProvider.js +++ b/src/phoenix/src/puter-shell/providers/CompositeCommandProvider.js @@ -54,4 +54,15 @@ export class CompositeCommandProvider { } return results; } + + async list() { + const results = []; + for (const provider of this.providers) { + if (typeof provider.list === 'function') { + const commands = await provider.list(); + results.push(...commands); + } + } + return results; + } }