diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..fda6101 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,58 @@ +module.exports = { + env: { + es6: true, + node: true, + }, + plugins: ['import', 'simple-import-sort'], + extends: ['eslint:recommended'], + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + rules: { + 'prettier/prettier': [ + 'error', + { + trailingComma: 'es5', + semi: false, + singleQuote: true, + arrowParens: 'always', + printWidth: 100, + }, + ], + 'object-shorthand': ['error', 'always'], + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint/eslint-plugin'], + extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/typescript'], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, + { + files: ['*'], + plugins: ['prettier'], + extends: ['plugin:prettier/recommended'], + }, + ], +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# 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* + +# Caches + +.cache + +# 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/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# 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 + +# 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/LICENSE b/LICENSE new file mode 100644 index 0000000..dfbf893 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mike Grabowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..673e8b6 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# dead-simple-ai-agent + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.38. [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..69845dd Binary files /dev/null and b/bun.lockb differ diff --git a/example/sales-marketing-tech/run.ts b/example/sales-marketing-tech/run.ts new file mode 100644 index 0000000..c4551b1 --- /dev/null +++ b/example/sales-marketing-tech/run.ts @@ -0,0 +1,59 @@ +import { Agent, Team } from '../../src/index.js' + +// Business Team Agents +const marketingManager = new Agent({ + prompt: `You are a Marketing Manager with expertise in: + - Digital marketing strategy + - Brand development and management + - Marketing campaign planning + - Analytics and performance tracking + - Content strategy and social media`, +}) + +const salesManager = new Agent({ + prompt: `You are a Sales Manager with expertise in: + - Sales strategy and pipeline management + - Customer relationship management + - Sales forecasting and analytics + - Team performance optimization + - Deal negotiation and closing strategies`, +}) + +const technicalManager = new Agent({ + prompt: `You are a Technical Manager with expertise in: + - Technical infrastructure planning + - System architecture and integration + - Technology stack evaluation + - Development process optimization + - Technical resource allocation`, +}) + +// Create team +const team = new Team({ + agents: [marketingManager, salesManager, technicalManager], +}) + +// Business alignment workflow +const alignmentWorkflow = ` + Quarterly Business Alignment Meeting Agenda: + - Review current marketing campaigns and their technical requirements + - Analyze sales pipeline and identify technology bottlenecks + - Discuss integration needs between CRM and marketing automation + - Plan resource allocation for upcoming projects + - Establish KPIs for cross-team collaboration + + Please provide recommendations for: + - Improving customer journey tracking across departments + - Streamlining lead handoff process + - Technical infrastructure improvements to support growth + + The goal is to create an action plan that aligns marketing initiatives, + sales objectives, and technical capabilities for the next quarter. +` + +// Run the workflow +console.log('🚀 Starting business alignment workflow...') + +await team.ask(alignmentWorkflow) + +console.log('✅ Alignment meeting completed successfully') diff --git a/package.json b/package.json new file mode 100644 index 0000000..51c99c3 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "dead-simple-ai-agent", + "description": "A dead simple AI agent framework", + "author": "Mike Grabowski ", + "type": "module", + "devDependencies": { + "@release-it-plugins/workspaces": "^4.2.0", + "@release-it/conventional-changelog": "^9.0.3", + "@rslib/core": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "bun-types": "^1.1.33", + "eslint": "^8.21.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-simple-import-sort": "^12.0.0", + "prettier": "^3.2.5", + "release-it": "^17.10.0", + "tsx": "^4.19.2", + "typescript": "^5.1.3", + "vitest": "^2.1.1" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "dedent": "^1.5.3", + "openai": "^4.76.0", + "zod": "^3.23.8" + }, + "trustedDependencies": [ + "core-js" + ] +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dbffebe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,351 @@ +import dedent from 'dedent' +import OpenAI from 'openai' +import { zodResponseFormat } from 'openai/helpers/zod' +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { z } from 'zod' + +// tbd: abstract this away or not? most APIs are OpenAI compatible +const openai = new OpenAI() + +interface Protocol { + requestUserInput(prompt: string): Promise +} + +// tbd: we should replace this with a "HumanInTheLoop" agent of CLI type +// to do so, we need to implement delegation across different agents +// so they can work collaboratively on smaller tasks too +class CLIProtocol implements Protocol { + async requestUserInput(prompt: string): Promise { + return new Promise((resolve) => { + console.log(prompt) + process.stdin.once('data', (data) => { + resolve(data.toString().trim()) + }) + }) + } +} + +type ToolDefinition> = { + name: string + description: string + parameters: T + execute: (parameters: z.infer) => Promise +} + +interface AgentConfig { + prompt?: string + tools?: ToolDefinition[] + model?: string + protocol?: Protocol +} + +// tbd: implement delegation +// tbd: implement short-term and long-term memory with different storage models +export class Agent { + private prompt: string + private tools: ToolDefinition[] + private model: string + private protocol: Protocol + + constructor({ + prompt = '', + tools = [], + model = 'gpt-4o', + protocol = new CLIProtocol(), + }: AgentConfig = {}) { + this.prompt = prompt + this.tools = tools + this.model = model + this.protocol = protocol + } + + async executeTask(messages: ChatCompletionMessageParam[]): Promise { + const response = await openai.beta.chat.completions.parse({ + model: this.model, + messages: [ + { + role: 'system', + content: dedent` + ${this.prompt} + + Your task is to complete the assigned work by: + 1. Breaking down the task into steps + 2. Using available tools when needed + 3. Requesting user input if required + 4. Providing clear progress updates + `, + }, + ...messages, + ], + // tbd: only add tools if there are any + // tools: this.tools.map(zodFunction), + response_format: zodResponseFormat( + z.object({ + response: z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('user_input'), + prompt: z.string().describe('The question to ask the user'), + reasoning: z.string().describe('The reasoning for asking this question'), + }), + z.object({ + kind: z.literal('complete'), + result: z.string().describe('The final result of the task'), + reasoning: z.string().describe('The reasoning for completing the task'), + }), + ]), + }), + 'task_result' + ), + }) + if (response.choices[0].message.tool_calls.length > 0) { + const toolResults = await Promise.all( + response.choices[0].message.tool_calls.map(async (toolCall) => { + if (toolCall.type !== 'function') { + throw new Error('Tool call is not a function') + } + + const tool = this.tools.find((t) => t.name === toolCall.function.name) + if (!tool) { + throw new Error(`Unknown tool: ${toolCall.function.name}`) + } + + const parameters = tool.parameters.parse(toolCall.function.arguments) + const content = await tool.execute(parameters) + + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + content: JSON.stringify(content), + } + }) + ) + + return this.executeTask([...messages, response.choices[0].message, ...toolResults]) + } + + // tbd: verify shape of response + const result = response.choices[0].message.parsed + if (!result) { + throw new Error('No parsed response received') + } + + if (result.response.kind === 'user_input') { + const userInput = await this.requestUserInput(result.response.prompt) + return this.executeTask([ + ...messages, + { + role: 'assistant', + content: result.response.prompt, + }, + { + role: 'user', + content: userInput, + }, + ]) + } + + if (result.response.kind === 'complete') { + return result.response.result + } + + // tbd: check if this is reachable + throw new Error('Illegal state') + } + + async requestUserInput(prompt: string): Promise { + return this.protocol.requestUserInput(prompt) + } +} + +interface WorkflowState { + workflow: string + messages: ChatCompletionMessageParam[] +} + +class Supervisor { + private agents: Agent[] = [] + private state: WorkflowState | null = null + + constructor(agents: Agent[]) { + this.agents = agents + } + + private async getNextTask(state: WorkflowState): Promise { + const response = await openai.beta.chat.completions.parse({ + model: 'gpt-4o', + messages: [ + { + role: 'system', + // tbd: improve prompt for generic workflow + // tbd: handle subsequent failures + content: dedent` + You are a workflow planner that breaks down complex tasks into smaller, actionable steps. + Your job is to determine the next task that needs to be done based on the original workflow and what has been completed so far. + If all required tasks are completed, return null. + + Rules: + 1. Each task should be self-contained and achievable + 2. Tasks should be specific and actionable + 3. Return null when the workflow is complete + 4. Consider dependencies and order of operations + 5. Use context from completed tasks to inform next steps + `, + }, + ...state.messages, + { + role: 'user', + content: 'What is the next task that needs to be done?', + }, + ], + temperature: 0.2, + response_format: zodResponseFormat( + z.object({ + nextTask: z + .string() + .describe('The next task to be completed or null if the workflow is complete'), + reasoning: z + .string() + .describe('The reasoning for selecting the next task or why the workflow is complete'), + }), + 'next_task' + ), + }) + + try { + const content = response.choices[0].message.parsed + if (!content) { + throw new Error('No content in response') + } + + if (!content.nextTask) { + return null + } + + return content.nextTask + } catch (error) { + throw new Error('Failed to determine next task') + } + } + + private async selectAgent(task: string): Promise { + const response = await openai.beta.chat.completions.parse({ + model: 'gpt-4o', + messages: [ + { + role: 'system', + content: dedent` + You are an agent selector that matches tasks to the most capable agent. + Analyze the task requirements and each agent's capabilities to select the best match. + + Consider: + 1. Required tools and skills + 2. Agent's specialization (via prompt) + 3. Model capabilities + 4. Previous task context if available + `, + }, + { + role: 'user', + content: dedent` + Task: + ${task} + + Available agents: + ${this.agents} + + Select the most suitable agent for this task. + `, + }, + ], + temperature: 0.1, + response_format: zodResponseFormat( + z.object({ + agentIndex: z.number(), + reasoning: z.string(), + }), + 'agent_selection' + ), + }) + + const content = response.choices[0].message.parsed + if (!content) { + throw new Error('No content in response') + } + + const agent = this.agents[content.agentIndex] + if (!agent) { + throw new Error('Invalid agent') + } + + return agent + } + + async executeWorkflow(workflow: string): Promise { + this.state = { + workflow, + messages: [ + { + role: 'user', + content: workflow, + }, + ], + } + + // tbd: set reasonable max iterations + // eslint-disable-next-line no-constant-condition + while (true) { + const task = await this.getNextTask(this.state) + + if (!task) { + break + } + + this.state.messages.push({ + role: 'assistant', + content: task, + }) + + // tbd: this throws, handle it + const selectedAgent = await this.selectAgent(task) + + // tbd: this should just be a try/catch + // tbd: do not return string, but more information or keep memory in agent + try { + const result = await selectedAgent.executeTask([ + { + role: 'user', + content: task, + }, + ]) + + this.state.messages.push({ + role: 'assistant', + content: result, + }) + } catch (error) { + console.log('💬', error) + this.state.messages.push({ + role: 'assistant', + content: dedent` + An error occurred while executing the task: + ${error instanceof Error ? error.message : 'Unknown error'} + `, + }) + } + } + } +} + +export class Team { + private agents: Agent[] + private supervisor: Supervisor + + constructor({ agents = [] }: { agents: Agent[] }) { + this.agents = agents + this.supervisor = new Supervisor(agents) + } + + async ask(workflow: string): Promise { + await this.supervisor.executeWorkflow(workflow) + } +} diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..e4f40ec --- /dev/null +++ b/test.ts @@ -0,0 +1,59 @@ +import { Agent, Team } from './index' + +// Business Team Agents +const marketingManager = new Agent({ + prompt: `You are a Marketing Manager with expertise in: + - Digital marketing strategy + - Brand development and management + - Marketing campaign planning + - Analytics and performance tracking + - Content strategy and social media`, +}) + +const salesManager = new Agent({ + prompt: `You are a Sales Manager with expertise in: + - Sales strategy and pipeline management + - Customer relationship management + - Sales forecasting and analytics + - Team performance optimization + - Deal negotiation and closing strategies`, +}) + +const technicalManager = new Agent({ + prompt: `You are a Technical Manager with expertise in: + - Technical infrastructure planning + - System architecture and integration + - Technology stack evaluation + - Development process optimization + - Technical resource allocation`, +}) + +// Create team +const team = new Team({ + agents: [marketingManager, salesManager, technicalManager], +}) + +// Business alignment workflow +const alignmentWorkflow = ` + Quarterly Business Alignment Meeting Agenda: + - Review current marketing campaigns and their technical requirements + - Analyze sales pipeline and identify technology bottlenecks + - Discuss integration needs between CRM and marketing automation + - Plan resource allocation for upcoming projects + - Establish KPIs for cross-team collaboration + + Please provide recommendations for: + - Improving customer journey tracking across departments + - Streamlining lead handoff process + - Technical infrastructure improvements to support growth + + The goal is to create an action plan that aligns marketing initiatives, + sales objectives, and technical capabilities for the next quarter. +` + +// Run the workflow +console.log('🚀 Starting business alignment workflow...') + +await team.ask(alignmentWorkflow) + +console.log('✅ Alignment meeting completed successfully') diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ade9808 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "NodeNext", + "moduleResolution": "nodenext", + "allowJs": true, + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "outDir": "dist", + "resolveJsonModule": true, + "customConditions": ["source"] + } +}