commit 543f35e6ae382ca31a330c67f32ba8bad8c0b26f Author: Wlad Paiva Date: Tue Oct 10 21:10:51 2023 -0300 add chat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab5afb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..333f9fb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "launch", + "name": "Debug File", + "program": "${file}", + "cwd": "${workspaceFolder}", + "stopOnEntry": false, + "watchMode": false + }, + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "launch", + "name": "Run File", + "program": "${file}", + "cwd": "${workspaceFolder}", + "noDebug": true, + "watchMode": false + }, + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "attach", + "name": "Attach Bun", + "url": "ws://localhost:6499/", + "stopOnEntry": false + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c189526 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# meu-autogen + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..c7b2f77 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4a258a --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "autogen", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "ai": "^2.2.14", + "openai": "^4.11.1" + } +} diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..0c92944 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,70 @@ +import { Message } from "./types"; + +/** + * (In preview) An abstract class for AI agent. + * + * An agent can communicate with other agents and perform actions. + * Different agents can differ in what actions they perform in the `receive` method. + */ +export abstract class Agent { + private _name: string; + + /** + * @param name name of the agent. + */ + constructor(name: string) { + this._name = name; + } + + /** + * Get the name of the agent. + * @returns The name of the agent. + */ + get name(): string { + return this._name; + } + + /** + * (Abstract method) Send a message to another agent. + * @abstract + * @param message The message to send. + * @param recipient The recipient agent. + * @param requestReply Whether a reply is requested. + */ + abstract send( + message: string | Message, + recipient: Agent, + requestReply?: boolean + ): Promise; + + /** + * (Abstract async method) Receive a message from another agent. + * @abstract + * @param message The message received. + * @param sender The sender agent. + * @param requestReply Whether a reply is requested. + */ + abstract receive( + message: string | Message, + sender: Agent, + requestReply?: boolean + ): Promise; + + /** + * (Abstract async method) Generate a reply based on the received messages. + * @abstract + * @param messages A list of messages received. + * @param sender The sender agent. + * @returns The generated reply. If None, no reply is generated. + */ + abstract generateReply( + messages?: Message[], + sender?: Agent + ): Promise; + + /** + * (Abstract method) Reset the agent. + * @abstract + */ + abstract reset(): void; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6d3739c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,404 @@ +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { Agent } from "./agent"; +import type { Callable, LlmConfig, Message, ReplyFunc, Role } from "./types"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +// const response = await openai.chat.completions.create({ +// model: "gpt-3.5-turbo", +// stream: true, +// messages: [ +// { +// content: "Hello, how are you?", +// role: "user", +// }, +// ], +// // functions: [ +// // { +// // name: "get_weather", +// // description: "Gets the weather in a given city.", +// // parameters: { +// // type: "object", +// // properties: { +// // city: { +// // type: "string", +// // description: "The city to get the weather for.", +// // }, +// // }, +// // } +// // } +// // ] +// }); + +// const stream = OpenAIStream(response); + +// // for await (const chunk of stream) { +// // process.stdout.write(chunk); +// // } + +// const result = new StreamingTextResponse(stream); +// const first = await result.text(); +// console.log("🔥 ~ ", first); + +// const response2 = await openai.chat.completions.create({ +// model: "gpt-3.5-turbo", +// stream: true, +// messages: [ +// { +// content: first, +// role: "user", +// }, +// ], +// }); + +// const stream2 = OpenAIStream(response2); +// const result2 = new StreamingTextResponse(stream2); +// const second = await result2.text(); +// console.log("🔴 ~ ", second); + +class ConversableAgent extends Agent { + private _messages: Map; + private replyFuncList: (ReplyFunc & { init_config: unknown })[] = []; + private onMessageReceived?: (message: Message, sender: Agent) => void; + private defaultAutoReply: string; + + constructor( + name: string, + config: { + /** + * Default auto reply. + * @default "" + */ + defaultAutoReply?: string; + } = {} + ) { + super(name); + this._messages = new Map(); + this.onMessageReceived = (message, sender) => { + console.log(`${sender.name}: ${message.content}`); + }; + const { defaultAutoReply = "" } = config; + this.defaultAutoReply = defaultAutoReply; + + this.registerReply({ + trigger: this, + replyFunc: this.generateOaiReply, + }); + + // TODO: implement those + // this.registerReply({ + // trigger: this, + // replyFunc: this.generate_code_execution_reply, + // }); + // this.registerReply({ + // trigger: this, + // replyFunc: this.generate_function_call_reply, + // }); + // this.registerReply({ + // trigger: this, + // replyFunc: this.check_termination_and_human_reply, + // }); + } + + /** + * Get chat messages. + * @returns The chat messages. + */ + get chatMessages() { + return this._messages; + } + + /** + * Convert a message to a dictionary. The message can be a string or a dictionary. + * The string will be put in the "content" field of the new dictionary. + * @param message The message to convert. + */ + static messageToDict(message: string | Message) { + return typeof message === "string" ? { content: message } : message; + } + + /** + * Append a message to the ChatCompletion conversation. + * - If the message received is a string, it will be put in the "content" field of the new dictionary. + * - If the message received is a dictionary but does not have any of the two fields "content" or "function_call", + * this message is not a valid ChatCompletion message. + * - If only "function_call" is provided, "content" will be set to None if not provided, and the role of the message will be forced "assistant". + * + * @param message The message to append. + * @param role The role of the message. + * @param agent The agent that sent the message. + * @returns whether the message is appended to the ChatCompletion conversation. + */ + public appendOaiMessage(message: string | Message, role: Role, agent: Agent) { + const converted = ConversableAgent.messageToDict(message); + + // create oai message to be appended to the oai conversation that can be passed to oai directly. + if ( + !converted.content && + (!("function_call" in converted) || !converted.function_call) + ) { + return false; + } + + const newMessage: Message = { + ...converted, + role: + "role" in converted && converted.role === "function" + ? "function" + : "function_call" in converted && converted.function_call + ? "assistant" + : role, + }; + + if (!this._messages.has(agent)) { + this._messages.set(agent, []); + } + + this._messages.get(agent)!.push(newMessage); + + return true; + } + + async send( + message: string | Message, + recipient: Agent, + requestReply?: boolean | undefined + ) { + // When the agent composes and sends the message, the role of the message is "assistant" + // unless it's "function". + const valid = this.appendOaiMessage(message, "assistant", recipient); + if (!valid) { + throw new Error( + "Message can't be converted into a valid ChatCompletion message. Either content or function_call must be provided." + ); + } + + await recipient.receive(message, this, requestReply); + } + + /** + * When the agent receives a message, the role of the message is "user". + * (If 'role' exists and is 'function', it will remain unchanged.) + * @param message + * @param sender + */ + private processReceivedMessage(message: string | Message, sender: Agent) { + const converted = ConversableAgent.messageToDict(message); + const valid = this.appendOaiMessage(message, "user", sender); + if (!valid) { + throw new Error( + "Received message can't be converted into a valid ChatCompletion message. Either content or function_call must be provided." + ); + } + this.onMessageReceived?.({ role: "user", ...converted }, sender); + } + + async receive( + message: string | Message, + sender: Agent, + requestReply?: boolean + ) { + this.processReceivedMessage(message, sender); + if (!requestReply) { + return; + } + + const reply = await this.generateReply( + this.chatMessages.get(sender), + sender + ); + if (reply) { + await this.send(reply, sender); + } + } + + /** + * Register a reply function. + * + * The reply function will be called when the trigger matches the sender. + * The function registered later will be checked earlier by default. + * To change the order, set the position to a positive integer. + */ + public registerReply({ + trigger, + replyFunc, + position = 0, + config, + resetConfig, + }: { + /** + * The trigger to activate the reply function. + * - If a class is provided, the reply function will be called when the sender is an instance of the class. + * - If a string is provided, the reply function will be called when the sender's name matches the string. + * - If an agent instance is provided, the reply function will be called when the sender is the agent instance. + * - If a callable is provided, the reply function will be called when the callable returns True. + * - If a list is provided, the reply function will be called when any of the triggers in the list is activated. + * - If None is provided, the reply function will be called only when the sender is None. + * Note: Be sure to register `None` as a trigger if you would like to trigger an auto-reply function with non-empty messages and `sender=None`. + */ + trigger: string | Agent | Callable | unknown[]; + /** + * The function takes a recipient agent, a list of messages, a sender agent and a config as input and returns a reply message. + */ + replyFunc: Callable; + /** + * The position of the reply function in the reply function list. + * The function registered later will be checked earlier by default. + * To change the order, set the position to a positive integer. + */ + position?: number; + /** + * The config to be passed to the reply function. + * When an agent is reset, the config will be reset to the original value. + */ + config?: LlmConfig; + /** + * the function to reset the config. + * The function returns None. + */ + resetConfig?: Callable; + }) { + if (!(trigger instanceof (Agent || String || Function || Array))) { + throw new Error( + "trigger must be a class, a string, an agent, a callable or a list." + ); + } + + this.replyFuncList.splice(position, 0, { + trigger, + replyFunc, + config, + init_config: config, + resetConfig, + }); + } + + /** + * Reply based on the conversation history and the sender. + * + * Either messages or sender must be provided. + * Register a reply_func with `None` as one trigger for it to be activated when `messages` is non-empty and `sender` is `None`. + * Use registered auto reply functions to generate replies. + * By default, the following functions are checked in order: + * 1. check_termination_and_human_reply + * 2. generate_function_call_reply + * 3. generate_code_execution_reply + * 4. generate_oai_reply + * Every function returns a tuple (final, reply). + * When a function returns final=False, the next function will be checked. + * So by default, termination and human reply will be checked first. + * If not terminating and human reply is skipped, execute function or code and return the result. + * AI replies are generated only when no code execution is performed. + * @param messages a list of messages in the conversation history. + * @param sender sender of an Agent instance. + * @param exclude a list of functions to exclude. + * @returns reply. None if no reply is generated. + */ + async generateReply( + messages?: Message[], + sender?: Agent + ): Promise { + if (!messages && !sender) { + throw new Error("Either messages or sender must be provided."); + } + + if (!messages) { + messages = this.chatMessages.get(sender!) || []; + } + + for (const replyFuncTuple of this.replyFuncList) { + const replyFunc = replyFuncTuple.replyFunc; + if (replyFuncTuple.trigger instanceof Agent) { + const { success, reply } = await replyFunc( + messages, + sender, + replyFuncTuple.config + ); + if (success) { + return reply; + } + } + } + + return this.defaultAutoReply; + } + + /** + * Generate a reply using `autogen.oai` + * @param messages + * @param sender + * @param config + */ + public async generateOaiReply( + messages?: Message[], + sender?: Agent, + config?: LlmConfig + ): Promise< + | { + success: false; + reply: null; + } + | { + success: true; + reply: string; + } + > { + // TODO: config + // const llmConfig = config || this.llmConfig; + + if (!sender) { + throw new Error("Sender must be provided."); + } + + if (!messages) { + messages = this._messages.get(sender); + } + + // TODO: implement this + // response = oai.ChatCompletion.create( + // context=messages[-1].pop("context", None), messages=self._oai_system_message + messages, **llm_config + // ) + // return True, oai.ChatCompletion.extract_text_or_function_call(response)[0] + const response2 = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + stream: true, + messages: messages!, + }); + + const stream2 = OpenAIStream(response2); + const result2 = new StreamingTextResponse(stream2); + const second = await result2.text(); + + return { + success: true, + reply: second, + } as const; + } + + reset(): void { + throw new Error("Method not implemented."); + } +} + +const first = new ConversableAgent("🔥"); +const second = new ConversableAgent("🔴"); +await first.send( + { content: "Hello, how are you?", role: "user" }, + second, + true +); + +// TODO: 1. Configuration +// TODO: 2. generate_function_call_reply +// TODO: 3. check_termination_and_human_reply +// TODO: 4. Cache +// TODO: 5. UserProxy +// TODO: 6. Assistant +// TODO: 7. Group +// TODO: 8. GroupManager + +console.log("🔥 ~ first.chatMessages", first.chatMessages); +console.log("🔥 ~ second.chatMessages", second.chatMessages); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..45aa5e3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,65 @@ +import type OpenAI from "openai"; +import type { Agent } from "./agent"; + +/** + * OpenAI Chat API message. + */ +export type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam; +export type Role = OpenAI.Chat.Completions.ChatCompletionMessageParam["role"]; +export type Callable = ( + messages?: Message[], + sender?: Agent, + config?: LlmConfig +) => Promise< + | { + success: false; + reply: null; + } + | { + success: true; + reply: string; + } +>; +export type LlmConfig = { + // TODO: Add types for this +}; + +export type ReplyFunc = { + /** + * The trigger to activate the reply function. + * - If a class is provided, the reply function will be called when the sender is an instance of the class. + * - If a string is provided, the reply function will be called when the sender's name matches the string. + * - If an agent instance is provided, the reply function will be called when the sender is the agent instance. + * - If a callable is provided, the reply function will be called when the callable returns True. + * - If a list is provided, the reply function will be called when any of the triggers in the list is activated. + * - If None is provided, the reply function will be called only when the sender is None. + * Note: Be sure to register `None` as a trigger if you would like to trigger an auto-reply function with non-empty messages and `sender=None`. + */ + trigger: string | Agent | Callable | unknown[]; + /** + * The function takes a recipient agent, a list of messages, a sender agent and a config as input and returns a reply message. + */ + replyFunc: Callable; + /** + * The position of the reply function in the reply function list. + * The function registered later will be checked earlier by default. + * To change the order, set the position to a positive integer. + */ + position?: number; + /** + * The config to be passed to the reply function. + * When an agent is reset, the config will be reset to the original value. + */ + config?: LlmConfig; + /** + * the function to reset the config. + * The function returns None. + */ + resetConfig?: Callable; +}; + +export type MessageXXX = Message & { + // TODO: + id: string; + createdAt?: Date; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7556e1d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" // add Bun global + ] + } +}