Jelajahi Sumber

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
Ntwari Bruce 1 bulan lalu
induk
melakukan
39048a9e2e

+ 1 - 0
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",

+ 129 - 61
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);
                 }
-
-                await next_line();
-
-                const inputString = new TextDecoder().decode(line);
-                const response = (inputString ?? '').trim().toLowerCase();
-
-                console.log('processed response', {response});
-
-                if (!response.startsWith('y')) {
-                    await ctx.externs.out.write('\nCommand execution cancelled\n');
-                    return; 
+               
+                else if (chunk.type === 'tool_use' && chunk.name) {
+                    const args = chunk.input;
+                    const command = await ctx.externs.commandProvider.lookup(chunk.name);
+
+                    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}`;
+                                }
+                            }
+                        }
+
+                        if (args.path) {
+                            cmdString += ` ${args.path}`;
+                        }
+
+                        await ctx.externs.out.write(`\nExecuting: ${cmdString}\n`);
+                        await ctx.externs.out.write('Proceed? (y/n): ');
+
+                        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
+        });
+
+    } 
+        
+}

+ 0 - 6
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:

+ 8 - 0
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
+        }));
+    }
+
 }

+ 11 - 0
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;
+    }
 }