├── .env.example ├── .eslintrc.js ├── .github └── workflows │ ├── docs-sync.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── examples ├── custom-agent.ts ├── marketing-agent.ts ├── system.md └── twitter-agent.ts ├── index.ts ├── package-lock.json ├── package.json ├── src ├── agent.ts ├── capability.ts ├── index.ts ├── logger.ts ├── openai-tools.json └── types.ts ├── test ├── agent.test.ts ├── api.test.ts ├── capabilities.test.ts ├── logger.test.ts ├── test-utils.ts └── types.test.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_ORGANIZATION= 2 | OPENAI_API_KEY= 3 | OPENSERV_API_KEY= 4 | OPENSERV_AUTH_TOKEN= 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true 4 | }, 5 | parser: '@typescript-eslint/parser', 6 | plugins: ['@typescript-eslint', 'prettier'], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended' 11 | ], 12 | rules: { 13 | 'prettier/prettier': 'error', 14 | '@typescript-eslint/explicit-function-return-type': 'off', 15 | '@typescript-eslint/no-explicit-any': 'off' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/docs-sync.yml: -------------------------------------------------------------------------------- 1 | name: Sync README to openserv-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'README.md' 9 | 10 | jobs: 11 | sync-readme: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sdk repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Checkout openserv-docs repository 18 | uses: actions/checkout@v2 19 | with: 20 | repository: 'openserv-labs/openserv-docs' 21 | token: ${{ secrets.GH_TOKEN }} 22 | path: 'openserv-docs' 23 | 24 | - name: Copy README.md to openserv-docs 25 | run: | 26 | cp README.md openserv-docs/packages/sdk/README.md 27 | 28 | - name: Commit and push if changed 29 | working-directory: ./openserv-docs 30 | run: | 31 | git config --local user.email "action@github.com" 32 | git config --local user.name "GitHub Action" 33 | git add packages/sdk/README.md 34 | git commit -m "Update README from sdk repository" || exit 0 35 | git push 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: SDK Integration Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22.x' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Create env file 26 | run: | 27 | echo "OPENSERV_API_KEY=test-key" > .env 28 | 29 | - name: Run tests 30 | run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Coverage 8 | coverage/ 9 | 10 | # Environment variables 11 | .env 12 | .env.* 13 | 14 | # IDE 15 | .vscode/ 16 | .idea/ 17 | 18 | # Logs 19 | *.log 20 | npm-debug.log* 21 | 22 | # OS 23 | .DS_Store 24 | Thumbs.db 25 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source 2 | src/ 3 | examples/ 4 | tests/ 5 | 6 | # Config files 7 | .eslintrc.js 8 | .prettierrc 9 | .prettierignore 10 | tsconfig.json 11 | .env* 12 | .vscode/ 13 | .github/ 14 | 15 | # Development files 16 | *.log 17 | *.tsbuildinfo 18 | coverage/ 19 | .nyc_output/ 20 | .DS_Store 21 | 22 | # Dependencies 23 | node_modules/ 24 | 25 | # Git 26 | .git/ 27 | .gitignore 28 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "trailingComma": "none", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug example agent", 8 | "runtimeExecutable": "npm", 9 | "runtimeArgs": [ 10 | "run", 11 | "dev:example" 12 | ], 13 | "skipFiles": [ 14 | "/**" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/dist/**/*.js" 18 | ], 19 | "sourceMaps": true, 20 | "resolveSourceMapLocations": [ 21 | "${workspaceFolder}/**", 22 | "!**/node_modules/**" 23 | ], 24 | "console": "integratedTerminal" 25 | }, 26 | { 27 | "type": "node", 28 | "request": "launch", 29 | "name": "Debug custom agent", 30 | "runtimeExecutable": "npm", 31 | "runtimeArgs": [ 32 | "run", 33 | "dev:custom-agent" 34 | ], 35 | "console": "integratedTerminal" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[javascript]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[json]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[markdown]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "eslint.validate": [ 20 | "typescript", 21 | "javascript" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 OpenServ Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenServ TypeScript SDK, Autonomous AI Agent Development Framework 2 | 3 | [![npm version](https://badge.fury.io/js/@openserv-labs%2Fsdk.svg)](https://www.npmjs.com/package/@openserv-labs/sdk) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/) 6 | 7 | A powerful TypeScript framework for building non-deterministic AI agents with advanced cognitive capabilities like reasoning, decision-making, and inter-agent collaboration within the OpenServ platform. Built with strong typing, extensible architecture, and a fully autonomous agent runtime. 8 | 9 | ## Table of Contents 10 | 11 | - [OpenServ TypeScript SDK, Autonomous AI Agent Development Framework](#openserv-typescript-sdk-autonomous-ai-agent-development-framework) 12 | - [Table of Contents](#table-of-contents) 13 | - [Features](#features) 14 | - [Framework Architecture](#framework-architecture) 15 | - [Framework \& Blockchain Compatibility](#framework--blockchain-compatibility) 16 | - [Shadow Agents](#shadow-agents) 17 | - [Control Levels](#control-levels) 18 | - [Developer Focus](#developer-focus) 19 | - [Installation](#installation) 20 | - [Getting Started](#getting-started) 21 | - [Platform Setup](#platform-setup) 22 | - [Agent Registration](#agent-registration) 23 | - [Development Setup](#development-setup) 24 | - [Quick Start](#quick-start) 25 | - [Environment Variables](#environment-variables) 26 | - [Core Concepts](#core-concepts) 27 | - [Capabilities](#capabilities) 28 | - [Tasks](#tasks) 29 | - [Chat Interactions](#chat-interactions) 30 | - [File Operations](#file-operations) 31 | - [API Reference](#api-reference) 32 | - [Task Management](#task-management) 33 | - [Create Task](#create-task) 34 | - [Update Task Status](#update-task-status) 35 | - [Add Task Log](#add-task-log) 36 | - [Chat \& Communication](#chat--communication) 37 | - [Send Message](#send-message) 38 | - [Request Human Assistance](#request-human-assistance) 39 | - [Workspace Management](#workspace-management) 40 | - [Get Files](#get-files) 41 | - [Upload File](#upload-file) 42 | - [Integration Management](#integration-management) 43 | - [Call Integration](#call-integration) 44 | - [Advanced Usage](#advanced-usage) 45 | - [OpenAI Process Runtime](#openai-process-runtime) 46 | - [Error Handling](#error-handling) 47 | - [Custom Agents](#custom-agents) 48 | - [Examples](#examples) 49 | - [License](#license) 50 | 51 | ## Features 52 | 53 | - 🔌 Advanced cognitive capabilities with reasoning and decision-making 54 | - 🤝 Inter-agent collaboration and communication 55 | - 🔌 Extensible agent architecture with custom capabilities 56 | - 🔧 Fully autonomous agent runtime with shadow agents 57 | - 🌐 Framework-agnostic - integrate agents from any AI framework 58 | - ⛓️ Blockchain-agnostic - compatible with any chain implementation 59 | - 🤖 Task execution and chat message handling 60 | - 🔄 Asynchronous task management 61 | - 📁 File operations and management 62 | - 🤝 Smart human assistance integration 63 | - 📝 Strong TypeScript typing with Zod schemas 64 | - 📊 Built-in logging and error handling 65 | - 🎯 Three levels of control for different development needs 66 | 67 | ## Framework Architecture 68 | 69 | ### Framework & Blockchain Compatibility 70 | 71 | OpenServ is designed to be completely framework and blockchain agnostic, allowing you to: 72 | 73 | - Integrate agents built with any AI framework (e.g., LangChain, BabyAGI, Eliza, G.A.M.E, etc.) 74 | - Connect agents operating on any blockchain network 75 | - Mix and match different framework agents in the same workspace 76 | - Maintain full compatibility with your existing agent implementations 77 | 78 | This flexibility ensures you can: 79 | 80 | - Use your preferred AI frameworks and tools 81 | - Leverage existing agent implementations 82 | - Integrate with any blockchain ecosystem 83 | - Build cross-framework agent collaborations 84 | 85 | ### Shadow Agents 86 | 87 | Each agent is supported by two "shadow agents": 88 | 89 | - Decision-making agent for cognitive processing 90 | - Validation agent for output verification 91 | 92 | This ensures smarter and more reliable agent performance without additional development effort. 93 | 94 | ### Control Levels 95 | 96 | OpenServ offers three levels of control to match your development needs: 97 | 98 | 1. **Fully Autonomous (Level 1)** 99 | 100 | - Only build your agent's capabilities 101 | - OpenServ's "second brain" handles everything else 102 | - Built-in shadow agents manage decision-making and validation 103 | - Perfect for rapid development 104 | 105 | 2. **Guided Control (Level 2)** 106 | 107 | - Natural language guidance for agent behavior 108 | - Balanced approach between control and simplicity 109 | - Ideal for customizing agent behavior without complex logic 110 | 111 | 3. **Full Control (Level 3)** 112 | - Complete customization of agent logic 113 | - Custom validation mechanisms 114 | - Override task and chat message handling for specific requirements 115 | 116 | ### Developer Focus 117 | 118 | The framework caters to two types of developers: 119 | 120 | - **Agent Developers**: Focus on building task functionality 121 | - **Logic Developers**: Shape agent decision-making and cognitive processes 122 | 123 | ## Installation 124 | 125 | ```bash 126 | npm install @openserv-labs/sdk 127 | ``` 128 | 129 | ## Getting Started 130 | 131 | ### Platform Setup 132 | 133 | 1. **Log In to the Platform** 134 | 135 | - Visit [OpenServ Platform](https://platform.openserv.ai) and log in using your Google account 136 | - This gives you access to developer tools and features 137 | 138 | 2. **Set Up Developer Account** 139 | - Navigate to the Developer menu in the left sidebar 140 | - Click on Profile to set up your developer account 141 | 142 | ### Agent Registration 143 | 144 | 1. **Register Your Agent** 145 | 146 | - Navigate to Developer -> Add Agent 147 | - Fill out required details: 148 | - Agent Name 149 | - Description 150 | - Capabilities Description (important for task matching) 151 | - Agent Endpoint (after deployment) 152 | 153 | 2. **Create API Key** 154 | - Go to Developer -> Your Agents 155 | - Open your agent's details 156 | - Click "Create Secret Key" 157 | - Store this key securely 158 | 159 | ### Development Setup 160 | 161 | 1. **Set Environment Variables** 162 | 163 | ```bash 164 | # Required 165 | export OPENSERV_API_KEY=your_api_key_here 166 | 167 | # Optional 168 | export OPENAI_API_KEY=your_openai_key_here # If using OpenAI process runtime 169 | export PORT=7378 # Custom port (default: 7378) 170 | ``` 171 | 172 | 2. **Initialize Your Agent** 173 | 174 | ```typescript 175 | import { Agent } from '@openserv-labs/sdk' 176 | import { z } from 'zod' 177 | 178 | const agent = new Agent({ 179 | systemPrompt: 'You are a specialized agent that...' 180 | }) 181 | 182 | // Add capabilities using the addCapability method 183 | agent.addCapability({ 184 | name: 'greet', 185 | description: 'Greet a user by name', 186 | schema: z.object({ 187 | name: z.string().describe('The name of the user to greet') 188 | }), 189 | async run({ args }) { 190 | return `Hello, ${args.name}! How can I help you today?` 191 | } 192 | }) 193 | 194 | // Start the agent server 195 | agent.start() 196 | ``` 197 | 198 | 3. **Deploy Your Agent** 199 | 200 | - Deploy your agent to a publicly accessible URL 201 | - Update the Agent Endpoint in your agent details 202 | - Ensure accurate Capabilities Description for task matching 203 | 204 | 4. **Test Your Agent** 205 | - Find your agent under the Explore section 206 | - Start a project with your agent 207 | - Test interactions with other marketplace agents 208 | 209 | ## Quick Start 210 | 211 | Create a simple agent with a greeting capability: 212 | 213 | ```typescript 214 | import { Agent } from '@openserv-labs/sdk' 215 | import { z } from 'zod' 216 | 217 | // Initialize the agent 218 | const agent = new Agent({ 219 | systemPrompt: 'You are a helpful assistant.', 220 | apiKey: process.env.OPENSERV_API_KEY 221 | }) 222 | 223 | // Add a capability 224 | agent.addCapability({ 225 | name: 'greet', 226 | description: 'Greet a user by name', 227 | schema: z.object({ 228 | name: z.string().describe('The name of the user to greet') 229 | }), 230 | async run({ args }) { 231 | return `Hello, ${args.name}! How can I help you today?` 232 | } 233 | }) 234 | 235 | // Or add multiple capabilities at once 236 | agent.addCapabilities([ 237 | { 238 | name: 'farewell', 239 | description: 'Say goodbye to a user', 240 | schema: z.object({ 241 | name: z.string().describe('The name of the user to bid farewell') 242 | }), 243 | async run({ args }) { 244 | return `Goodbye, ${args.name}! Have a great day!` 245 | } 246 | }, 247 | { 248 | name: 'help', 249 | description: 'Show available commands', 250 | schema: z.object({}), 251 | async run() { 252 | return 'Available commands: greet, farewell, help' 253 | } 254 | } 255 | ]) 256 | 257 | // Start the agent server 258 | agent.start() 259 | ``` 260 | 261 | ## Environment Variables 262 | 263 | | Variable | Description | Required | Default | 264 | | ------------------ | ------------------------------------- | -------- | ------- | 265 | | `OPENSERV_API_KEY` | Your OpenServ API key | Yes | - | 266 | | `OPENAI_API_KEY` | OpenAI API key (for process() method) | No\* | - | 267 | | `PORT` | Server port | No | 7378 | 268 | 269 | \*Required if using OpenAI integration features 270 | 271 | ## Core Concepts 272 | 273 | ### Capabilities 274 | 275 | Capabilities are the building blocks of your agent. Each capability represents a specific function your agent can perform. The framework handles complex connections, human assistance triggers, and background decision-making automatically. 276 | 277 | Each capability must include: 278 | 279 | - `name`: Unique identifier for the capability 280 | - `description`: What the capability does 281 | - `schema`: Zod schema defining the parameters 282 | - `run`: Function that executes the capability, receiving validated args and action context 283 | 284 | ```typescript 285 | import { Agent } from '@openserv-labs/sdk' 286 | import { z } from 'zod' 287 | 288 | const agent = new Agent({ 289 | systemPrompt: 'You are a helpful assistant.' 290 | }) 291 | 292 | // Add a single capability 293 | agent.addCapability({ 294 | name: 'summarize', 295 | description: 'Summarize a piece of text', 296 | schema: z.object({ 297 | text: z.string().describe('Text content to summarize'), 298 | maxLength: z.number().optional().describe('Maximum length of summary') 299 | }), 300 | async run({ args, action }) { 301 | const { text, maxLength = 100 } = args 302 | 303 | // Your summarization logic here 304 | const summary = `Summary of text (${text.length} chars): ...` 305 | 306 | // Log progress to the task 307 | await this.addLogToTask({ 308 | workspaceId: action.workspace.id, 309 | taskId: action.task.id, 310 | severity: 'info', 311 | type: 'text', 312 | body: 'Generated summary successfully' 313 | }) 314 | 315 | return summary 316 | } 317 | }) 318 | 319 | // Add multiple capabilities at once 320 | agent.addCapabilities([ 321 | { 322 | name: 'analyze', 323 | description: 'Analyze text for sentiment and keywords', 324 | schema: z.object({ 325 | text: z.string().describe('Text to analyze') 326 | }), 327 | async run({ args, action }) { 328 | // Implementation here 329 | return JSON.stringify({ result: 'analysis complete' }) 330 | } 331 | }, 332 | { 333 | name: 'help', 334 | description: 'Show available commands', 335 | schema: z.object({}), 336 | async run({ args, action }) { 337 | return 'Available commands: summarize, analyze, help' 338 | } 339 | } 340 | ]) 341 | ``` 342 | 343 | Each capability's run function receives: 344 | 345 | - `params`: Object containing: 346 | - `args`: The validated arguments matching the capability's schema 347 | - `action`: The action context containing: 348 | - `task`: The current task context (if running as part of a task) 349 | - `workspace`: The current workspace context 350 | - `me`: Information about the current agent 351 | - Other action-specific properties 352 | 353 | The run function must return a string or Promise. 354 | 355 | ### Tasks 356 | 357 | Tasks are units of work that agents can execute. They can have dependencies, require human assistance, and maintain state: 358 | 359 | ```typescript 360 | const task = await agent.createTask({ 361 | workspaceId: 123, 362 | assignee: 456, 363 | description: 'Analyze customer feedback', 364 | body: 'Process the latest survey results', 365 | input: 'survey_results.csv', 366 | expectedOutput: 'A summary of key findings', 367 | dependencies: [] // Optional task dependencies 368 | }) 369 | 370 | // Add progress logs 371 | await agent.addLogToTask({ 372 | workspaceId: 123, 373 | taskId: task.id, 374 | severity: 'info', 375 | type: 'text', 376 | body: 'Starting analysis...' 377 | }) 378 | 379 | // Update task status 380 | await agent.updateTaskStatus({ 381 | workspaceId: 123, 382 | taskId: task.id, 383 | status: 'in-progress' 384 | }) 385 | ``` 386 | 387 | ### Chat Interactions 388 | 389 | Agents can participate in chat conversations and maintain context: 390 | 391 | ```typescript 392 | const customerSupportAgent = new Agent({ 393 | systemPrompt: 'You are a customer support agent.', 394 | capabilities: [ 395 | { 396 | name: 'respondToCustomer', 397 | description: 'Generate a response to a customer inquiry', 398 | schema: z.object({ 399 | query: z.string(), 400 | context: z.string().optional() 401 | }), 402 | func: async ({ query, context }) => { 403 | // Generate response using the query and optional context 404 | return `Thank you for your question about ${query}...` 405 | } 406 | } 407 | ] 408 | }) 409 | 410 | // Send a chat message 411 | await agent.sendChatMessage({ 412 | workspaceId: 123, 413 | agentId: 456, 414 | message: 'How can I assist you today?' 415 | }) 416 | 417 | // Get agent chat 418 | await agent.getChatMessages({ 419 | workspaceId: 123, 420 | agentId: 456 421 | }) 422 | ``` 423 | 424 | ### File Operations 425 | 426 | Agents can work with files in their workspace: 427 | 428 | ```typescript 429 | // Upload a file 430 | await agent.uploadFile({ 431 | workspaceId: 123, 432 | path: 'reports/analysis.txt', 433 | file: 'Analysis results...', 434 | skipSummarizer: false, 435 | taskIds: [456] // Associate with tasks 436 | }) 437 | 438 | // Get workspace files 439 | const files = await agent.getFiles({ 440 | workspaceId: 123 441 | }) 442 | ``` 443 | 444 | ## API Reference 445 | 446 | ### Task Management 447 | 448 | #### Create Task 449 | 450 | ```typescript 451 | const task = await agent.createTask({ 452 | workspaceId: number, 453 | assignee: number, 454 | description: string, 455 | body: string, 456 | input: string, 457 | expectedOutput: string, 458 | dependencies: number[] 459 | }) 460 | ``` 461 | 462 | #### Update Task Status 463 | 464 | ```typescript 465 | await agent.updateTaskStatus({ 466 | workspaceId: number, 467 | taskId: number, 468 | status: 'to-do' | 'in-progress' | 'human-assistance-required' | 'error' | 'done' | 'cancelled' 469 | }) 470 | ``` 471 | 472 | #### Add Task Log 473 | 474 | ```typescript 475 | await agent.addLogToTask({ 476 | workspaceId: number, 477 | taskId: number, 478 | severity: 'info' | 'warning' | 'error', 479 | type: 'text' | 'openai-message', 480 | body: string | object 481 | }) 482 | ``` 483 | 484 | ### Chat & Communication 485 | 486 | #### Send Message 487 | 488 | ```typescript 489 | await agent.sendChatMessage({ 490 | workspaceId: number, 491 | agentId: number, 492 | message: string 493 | }) 494 | ``` 495 | 496 | #### Request Human Assistance 497 | 498 | ```typescript 499 | await agent.requestHumanAssistance({ 500 | workspaceId: number, 501 | taskId: number, 502 | type: 'text' | 'project-manager-plan-review', 503 | question: string | object, 504 | agentDump?: object 505 | }) 506 | ``` 507 | 508 | ### Workspace Management 509 | 510 | #### Get Files 511 | 512 | ```typescript 513 | const files = await agent.getFiles({ 514 | workspaceId: number 515 | }) 516 | ``` 517 | 518 | #### Upload File 519 | 520 | ```typescript 521 | await agent.uploadFile({ 522 | workspaceId: number, 523 | path: string, 524 | file: Buffer | string, 525 | skipSummarizer?: boolean, 526 | taskIds?: number[] 527 | }) 528 | ``` 529 | 530 | ### Integration Management 531 | 532 | #### Call Integration 533 | 534 | ```typescript 535 | const response = await agent.callIntegration({ 536 | workspaceId: number, 537 | integrationId: string, 538 | details: { 539 | endpoint: string, 540 | method: string, 541 | data?: object 542 | } 543 | }) 544 | ``` 545 | 546 | Allows agents to interact with external services and APIs that are integrated with OpenServ. This method provides a secure way to make API calls to configured integrations within a workspace. Authentication is handled securely and automatically through the OpenServ platform. This is primarily useful for calling external APIs in a deterministic way. 547 | 548 | **Parameters:** 549 | 550 | - `workspaceId`: ID of the workspace where the integration is configured 551 | - `integrationId`: ID of the integration to call (e.g., 'twitter-v2', 'github') 552 | - `details`: Object containing: 553 | - `endpoint`: The endpoint to call on the integration 554 | - `method`: HTTP method (GET, POST, etc.) 555 | - `data`: Optional payload for the request 556 | 557 | **Returns:** The response from the integration endpoint 558 | 559 | **Example:** 560 | 561 | ```typescript 562 | // Example: Sending a tweet using Twitter integration 563 | const response = await agent.callIntegration({ 564 | workspaceId: 123, 565 | integrationId: 'twitter-v2', 566 | details: { 567 | endpoint: '/2/tweets', 568 | method: 'POST', 569 | data: { 570 | text: 'Hello from my AI agent!' 571 | } 572 | } 573 | }) 574 | ``` 575 | 576 | ## Advanced Usage 577 | 578 | ### OpenAI Process Runtime 579 | 580 | The framework includes built-in OpenAI function calling support through the `process()` method: 581 | 582 | ```typescript 583 | const result = await agent.process({ 584 | messages: [ 585 | { 586 | role: 'system', 587 | content: 'You are a helpful assistant' 588 | }, 589 | { 590 | role: 'user', 591 | content: 'Create a task to analyze the latest data' 592 | } 593 | ] 594 | }) 595 | ``` 596 | 597 | ### Error Handling 598 | 599 | Implement robust error handling in your agents: 600 | 601 | ```typescript 602 | try { 603 | await agent.doTask(action) 604 | } catch (error) { 605 | await agent.markTaskAsErrored({ 606 | workspaceId: action.workspace.id, 607 | taskId: action.task.id, 608 | error: error instanceof Error ? error.message : 'Unknown error' 609 | }) 610 | 611 | // Log the error 612 | await agent.addLogToTask({ 613 | workspaceId: action.workspace.id, 614 | taskId: action.task.id, 615 | severity: 'error', 616 | type: 'text', 617 | body: `Error: ${error.message}` 618 | }) 619 | } 620 | ``` 621 | 622 | ### Custom Agents 623 | 624 | Create specialized agents by extending the base Agent class: 625 | 626 | ```typescript 627 | class DataAnalysisAgent extends Agent { 628 | protected async doTask(action: z.infer) { 629 | if (!action.task) return 630 | 631 | try { 632 | await this.updateTaskStatus({ 633 | workspaceId: action.workspace.id, 634 | taskId: action.task.id, 635 | status: 'in-progress' 636 | }) 637 | 638 | // Implement custom analysis logic 639 | const result = await this.analyzeData(action.task.input) 640 | 641 | await this.completeTask({ 642 | workspaceId: action.workspace.id, 643 | taskId: action.task.id, 644 | output: JSON.stringify(result) 645 | }) 646 | } catch (error) { 647 | await this.handleError(action, error) 648 | } 649 | } 650 | 651 | private async analyzeData(input: string) { 652 | // Custom data analysis implementation 653 | } 654 | 655 | private async handleError(action: any, error: any) { 656 | // Custom error handling logic 657 | } 658 | } 659 | ``` 660 | 661 | ## Examples 662 | 663 | Check out our [examples directory](https://github.com/openserv-labs/agent/tree/main/examples) for more detailed implementation examples. 664 | 665 | ## License 666 | 667 | ``` 668 | MIT License 669 | 670 | Copyright (c) 2024 OpenServ Labs 671 | 672 | Permission is hereby granted, free of charge, to any person obtaining a copy 673 | of this software and associated documentation files (the "Software"), to deal 674 | in the Software without restriction, including without limitation the rights 675 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 676 | copies of the Software, and to permit persons to whom the Software is 677 | furnished to do so, subject to the following conditions: 678 | 679 | The above copyright notice and this permission notice shall be included in all 680 | copies or substantial portions of the Software. 681 | 682 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 683 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 684 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 685 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 686 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 687 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 688 | SOFTWARE. 689 | ``` 690 | 691 | --- 692 | 693 | Built with ❤️ by [OpenServ Labs](https://openserv.ai) 694 | -------------------------------------------------------------------------------- /examples/custom-agent.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import { Agent } from '../src' 5 | import type { z } from 'zod' 6 | import type { respondChatMessageActionSchema } from '../src/types' 7 | 8 | export class SophisticatedChatAgent extends Agent { 9 | protected async respondToChat(action: z.infer) { 10 | this.sendChatMessage({ 11 | workspaceId: action.workspace.id, 12 | agentId: action.me.id, 13 | message: 'This is a custom message' 14 | }) 15 | } 16 | } 17 | 18 | const agent = new SophisticatedChatAgent({ 19 | systemPrompt: 'You are a helpful assistant.' 20 | }) 21 | 22 | agent.start() 23 | -------------------------------------------------------------------------------- /examples/marketing-agent.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import { Agent } from '../src' 5 | import fs from 'node:fs' 6 | import path from 'node:path' 7 | import { z } from 'zod' 8 | import OpenAI from 'openai' 9 | import { logger } from '../src/logger' 10 | 11 | if (!process.env.OPENAI_API_KEY) { 12 | throw new Error('OPENAI_API_KEY environment variable is required') 13 | } 14 | 15 | const openai = new OpenAI({ 16 | apiKey: process.env.OPENAI_API_KEY 17 | }) 18 | 19 | const marketingManager = new Agent({ 20 | systemPrompt: fs.readFileSync(path.join(__dirname, './system.md'), 'utf8'), 21 | apiKey: process.env.OPENSERV_API_KEY, 22 | openaiApiKey: process.env.OPENAI_API_KEY 23 | }) 24 | 25 | marketingManager 26 | .addCapabilities([ 27 | { 28 | name: 'createSocialMediaPost', 29 | description: 'Creates a social media post for the specified platform', 30 | schema: z.object({ 31 | platform: z.enum(['twitter', 'linkedin', 'facebook']), 32 | topic: z.string() 33 | }), 34 | async run({ args }) { 35 | const completion = await openai.chat.completions.create({ 36 | model: 'gpt-4o', 37 | messages: [ 38 | { 39 | role: 'system', 40 | content: `You are a marketing expert. Create a compelling ${args.platform} post about: ${args.topic} 41 | 42 | Follow these platform-specific guidelines: 43 | - Twitter: Max 280 characters, casual tone, use hashtags 44 | - LinkedIn: Professional tone, industry insights, call to action 45 | - Facebook: Engaging, conversational, can be longer 46 | 47 | Include emojis where appropriate. Focus on driving engagement. 48 | 49 | Only generate post for the given platform. Don't generate posts for other platforms. 50 | 51 | Save the post in markdown format as a file and attach it to the task. 52 | ` 53 | }, 54 | { 55 | role: 'user', 56 | content: args.topic 57 | } 58 | ] 59 | }) 60 | 61 | const generatedPost = completion.choices[0].message.content 62 | logger.info(`Generated ${args.platform} post: ${generatedPost}`) 63 | 64 | return generatedPost || 'Failed to generate post' 65 | } 66 | }, 67 | { 68 | name: 'analyzeEngagement', 69 | description: 'Analyzes social media engagement metrics and provides recommendations', 70 | schema: z.object({ 71 | platform: z.enum(['twitter', 'linkedin', 'facebook']), 72 | metrics: z.object({ 73 | likes: z.number(), 74 | shares: z.number(), 75 | comments: z.number(), 76 | impressions: z.number() 77 | }) 78 | }), 79 | async run({ args }) { 80 | const completion = await openai.chat.completions.create({ 81 | model: 'gpt-4o', 82 | messages: [ 83 | { 84 | role: 'system', 85 | content: `You are a social media analytics expert. Analyze the engagement metrics and provide actionable recommendations. 86 | 87 | Consider platform-specific benchmarks: 88 | - Twitter: Engagement rate = (likes + shares + comments) / impressions 89 | - LinkedIn: Engagement rate = (likes + shares + comments) / impressions * 100 90 | - Facebook: Engagement rate = (likes + shares + comments) / impressions * 100 91 | 92 | Provide: 93 | 1. Current engagement rate 94 | 2. Performance assessment (below average, average, above average) 95 | 3. Top 3 actionable recommendations to improve engagement 96 | 4. Key metrics to focus on for improvement` 97 | }, 98 | { 99 | role: 'user', 100 | content: JSON.stringify(args) 101 | } 102 | ] 103 | }) 104 | 105 | const analysis = completion.choices[0].message.content 106 | logger.info(`Generated engagement analysis for ${args.platform}: ${analysis}`) 107 | 108 | return analysis || 'Failed to analyze engagement' 109 | } 110 | } 111 | ]) 112 | .start() 113 | .catch(console.error) 114 | -------------------------------------------------------------------------------- /examples/system.md: -------------------------------------------------------------------------------- 1 | Marketing Manager AI 2 | 3 | You are an AI marketing manager with extensive expertise in creating, executing, and optimizing marketing strategies. Your tone is professional yet approachable, and you always keep the target audience and business goals in mind. 4 | 5 | Your responsibilities include: 6 | • Designing and implementing marketing campaigns that align with business objectives. 7 | • Crafting engaging and persuasive copy for a variety of channels, including email, social media, blogs, and advertisements. 8 | • Analyzing market trends, customer insights, and campaign performance to provide actionable recommendations. 9 | • Ensuring all content and strategies reflect the brand’s voice, values, and positioning. 10 | • Collaborating with creative teams and stakeholders to deliver impactful results. 11 | 12 | Key Attributes: 13 | • Data-driven: Base decisions on metrics and analytics, and be ready to justify recommendations with evidence. 14 | • Creative: Offer innovative ideas to capture the target audience’s attention and differentiate from competitors. 15 | • Strategic: Think ahead and align efforts with long-term business goals while achieving short-term results. 16 | 17 | When asked, provide detailed, practical advice or create fully fleshed-out marketing materials. Always focus on maximizing value for the audience while delivering measurable results for the business. 18 | -------------------------------------------------------------------------------- /examples/twitter-agent.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import { Agent } from '../src' 5 | import fs from 'node:fs' 6 | import path from 'node:path' 7 | import { z } from 'zod' 8 | 9 | if (!process.env.OPENAI_API_KEY) { 10 | throw new Error('OPENAI_API_KEY environment variable is required') 11 | } 12 | 13 | const marketingManager = new Agent({ 14 | systemPrompt: fs.readFileSync(path.join(__dirname, './system.md'), 'utf8'), 15 | apiKey: process.env.OPENSERV_API_KEY, 16 | openaiApiKey: process.env.OPENAI_API_KEY 17 | }) 18 | 19 | marketingManager 20 | .addCapabilities([ 21 | { 22 | name: 'getTwitterAccount', 23 | description: 'Gets the Twitter account for the current user', 24 | schema: z.object({}), 25 | async run({ action }) { 26 | const details = await this.callIntegration({ 27 | workspaceId: action!.workspace.id, 28 | integrationId: 'twitter-v2', 29 | details: { 30 | endpoint: '/2/users/me', 31 | method: 'GET' 32 | } 33 | }) 34 | 35 | return details.output.data.username 36 | } 37 | }, 38 | { 39 | name: 'sendMarketingTweet', 40 | description: 'Sends a marketing tweet to Twitter', 41 | schema: z.object({ 42 | tweetText: z.string() 43 | }), 44 | async run({ args, action }) { 45 | const response = await this.callIntegration({ 46 | workspaceId: action!.workspace.id, 47 | integrationId: 'twitter-v2', 48 | details: { 49 | endpoint: '/2/tweets', 50 | method: 'POST', 51 | data: { 52 | text: args.tweetText 53 | } 54 | } 55 | }) 56 | 57 | console.log(response.output) 58 | 59 | try { 60 | const error = JSON.parse(JSON.parse(response.output.message)) 61 | 62 | return `Error ${error.status}: ${error.message}` 63 | } catch (e) { 64 | const output = response.output.data 65 | 66 | return output.text 67 | } 68 | } 69 | } 70 | ]) 71 | .start() 72 | .catch(console.error) 73 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { Agent } from './src/agent' 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openserv-labs/sdk", 3 | "version": "1.6.0", 4 | "description": "OpenServ Agent SDK - Create AI agents easily", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "dev": "tsc --watch", 10 | "dev:example": "ts-node-dev --respawn --transpile-only examples/marketing-agent.ts", 11 | "dev:twitter": "ts-node-dev --respawn --transpile-only examples/twitter-agent.ts", 12 | "dev:custom-agent": "ts-node-dev --respawn --transpile-only examples/custom-agent.ts", 13 | "check-types": "tsc --noEmit", 14 | "prepublishOnly": "npm run build && npm run lint && npm run check-types && npm run test", 15 | "prepare": "npm run build", 16 | "lint": "eslint . --ext .ts", 17 | "lint:fix": "eslint . --ext .ts --fix", 18 | "format": "prettier --write \"**/*.{ts,json,md}\"", 19 | "format:check": "prettier --check \"**/*.{ts,json,md}\"", 20 | "test": "node --import tsx --test test/**/*.test.ts", 21 | "test:watch": "node --import tsx --test --watch test/**/*.test.ts", 22 | "test:coverage": "node --import tsx --test --enable-source-maps --experimental-test-coverage --test-timeout=5000 test/**/*.test.ts" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/openserv-labs/sdk.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/openserv-labs/sdk/issues" 30 | }, 31 | "homepage": "https://github.com/openserv-labs/sdk#readme", 32 | "keywords": [ 33 | "ai", 34 | "agent", 35 | "sdk", 36 | "openserv", 37 | "llm", 38 | "function-calling", 39 | "typescript" 40 | ], 41 | "author": "OpenServ Labs", 42 | "license": "MIT", 43 | "dependencies": { 44 | "@asteasolutions/zod-to-openapi": "^7.3.0", 45 | "axios": "^1.6.8", 46 | "axios-retry": "^4.1.0", 47 | "bcryptjs": "^3.0.2", 48 | "compression": "^1.7.4", 49 | "express": "^4.19.2", 50 | "express-async-router": "^0.1.15", 51 | "helmet": "^8.0.0", 52 | "hpp": "^0.2.3", 53 | "http-errors": "^2.0.0", 54 | "pino": "^9.6.0", 55 | "pino-pretty": "^13.0.0", 56 | "zod": "^3.22.4", 57 | "zod-to-json-schema": "^3.22.4" 58 | }, 59 | "devDependencies": { 60 | "@tsconfig/strictest": "^2.0.3", 61 | "@types/compression": "^1.7.5", 62 | "@types/express": "^4.17.21", 63 | "@types/helmet": "^0.0.48", 64 | "@types/hpp": "^0.2.6", 65 | "@types/node": "^22.10.2", 66 | "@typescript-eslint/eslint-plugin": "^7.3.1", 67 | "@typescript-eslint/parser": "^7.3.1", 68 | "dotenv": "^16.4.5", 69 | "eslint": "^8.56.0", 70 | "eslint-config-prettier": "^9.1.0", 71 | "eslint-plugin-prettier": "^5.1.3", 72 | "prettier": "^3.2.5", 73 | "ts-node-dev": "^2.0.0", 74 | "tsx": "^4.19.2", 75 | "typescript": "^5.4.2" 76 | }, 77 | "files": [ 78 | "dist", 79 | "README.md", 80 | "LICENSE" 81 | ], 82 | "publishConfig": { 83 | "access": "public", 84 | "registry": "https://registry.npmjs.org/" 85 | }, 86 | "engines": { 87 | "node": ">=18.0.0" 88 | }, 89 | "peerDependencies": { 90 | "openai": "^5.0.1" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/agent.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosInstance } from 'axios' 2 | import bcrypt from 'bcryptjs' 3 | import compression from 'compression' 4 | import express, { type Handler } from 'express' 5 | import type { AsyncRouterInstance } from 'express-async-router' 6 | import { AsyncRouter } from 'express-async-router' 7 | import helmet from 'helmet' 8 | import hpp from 'hpp' 9 | import { logger } from './logger' 10 | import type http from 'node:http' 11 | import type { 12 | GetFilesParams, 13 | GetSecretsParams, 14 | GetSecretValueParams, 15 | UploadFileParams, 16 | DeleteFileParams, 17 | MarkTaskAsErroredParams, 18 | CompleteTaskParams, 19 | SendChatMessageParams, 20 | GetTaskDetailParams, 21 | GetAgentsParams, 22 | GetTasksParams, 23 | CreateTaskParams, 24 | AddLogToTaskParams, 25 | RequestHumanAssistanceParams, 26 | UpdateTaskStatusParams, 27 | ProcessParams, 28 | IntegrationCallRequest, 29 | GetChatMessagesParams, 30 | AgentChatMessagesResponse 31 | } from './types' 32 | import type { doTaskActionSchema, respondChatMessageActionSchema } from './types' 33 | import { actionSchema } from './types' 34 | import { BadRequest } from 'http-errors' 35 | import type { 36 | ChatCompletionMessageParam, 37 | ChatCompletionTool, 38 | ChatCompletion 39 | } from 'openai/resources/chat/completions' 40 | import { zodToJsonSchema } from 'zod-to-json-schema' 41 | import OpenAI from 'openai' 42 | import type { z } from 'zod' 43 | import { Capability } from './capability' 44 | 45 | const PLATFORM_URL = process.env.OPENSERV_API_URL || 'https://api.openserv.ai' 46 | const RUNTIME_URL = process.env.OPENSERV_RUNTIME_URL || 'https://agents.openserv.ai' 47 | const DEFAULT_PORT = Number.parseInt(process.env.PORT || '') || 7378 48 | const AUTH_TOKEN = process.env.OPENSERV_AUTH_TOKEN 49 | 50 | /** 51 | * Configuration options for creating a new Agent instance. 52 | */ 53 | export interface AgentOptions { 54 | /** 55 | * The port number for the agent's HTTP server. 56 | * Defaults to 7378 if not specified. 57 | */ 58 | port?: number 59 | 60 | /** 61 | * The OpenServ API key for authentication. 62 | * Can also be provided via OPENSERV_API_KEY environment variable. 63 | */ 64 | apiKey?: string 65 | 66 | /** 67 | * The system prompt that defines the agent's behavior and context. 68 | * Used as the initial system message in OpenAI chat completions. 69 | */ 70 | systemPrompt: string 71 | 72 | /** 73 | * The OpenAI API key for chat completions. 74 | * Can also be provided via OPENAI_API_KEY environment variable. 75 | * Required when using the process() method. 76 | */ 77 | openaiApiKey?: string 78 | 79 | /** 80 | * Error handler function for all agent operations. 81 | * Defaults to logging the error if not provided. 82 | * @param error - The error that occurred 83 | * @param context - Additional context about where the error occurred 84 | */ 85 | onError?: (error: Error, context?: Record) => void 86 | } 87 | 88 | const authTokenMiddleware: Handler = async (req, res, next) => { 89 | const tokenHash = req.headers['x-openserv-auth-token'] 90 | 91 | if (!tokenHash || typeof tokenHash !== 'string') { 92 | res.status(401).json({ error: 'Unauthorized' }) 93 | return 94 | } 95 | 96 | const isTokenValid = await bcrypt.compare(AUTH_TOKEN as string, tokenHash) 97 | 98 | if (!isTokenValid) { 99 | res.status(401).json({ error: 'Unauthorized' }) 100 | return 101 | } 102 | 103 | next() 104 | } 105 | 106 | export class Agent { 107 | /** 108 | * The Express application instance used to handle HTTP requests. 109 | * This is initialized in the constructor and used to set up middleware and routes. 110 | * @private 111 | */ 112 | private app: express.Application 113 | 114 | /** 115 | * The HTTP server instance created from the Express application. 116 | * This is initialized when start() is called and used to listen for incoming requests. 117 | * @private 118 | */ 119 | private server: http.Server | null = null 120 | 121 | /** 122 | * The Express router instance used to define API routes. 123 | * This handles routing for health checks, tool execution, and action handling. 124 | * @private 125 | */ 126 | private router: AsyncRouterInstance 127 | 128 | /** 129 | * The port number the server will listen on. 130 | * Defaults to DEFAULT_PORT (7378) if not specified in options. 131 | * @private 132 | */ 133 | private port: number 134 | 135 | /** 136 | * The system prompt used for OpenAI chat completions. 137 | * This defines the base behavior and context for the agent. 138 | * @protected 139 | */ 140 | protected systemPrompt: string 141 | 142 | /** 143 | * Array of capabilities (tools) available to the agent. 144 | * Each capability is an instance of the Capability class with a name, description, schema, and run function. 145 | * @protected 146 | */ 147 | protected tools: Array> = [] 148 | 149 | /** 150 | * The OpenServ API key used for authentication. 151 | * Can be provided in options or via OPENSERV_API_KEY environment variable. 152 | * @private 153 | */ 154 | private apiKey: string 155 | 156 | /** 157 | * Axios instance for making requests to the OpenServ API. 158 | * Pre-configured with base URL and authentication headers. 159 | * @private 160 | */ 161 | private apiClient: AxiosInstance 162 | 163 | /** 164 | * Axios instance for making requests to the OpenServ Runtime API. 165 | * Pre-configured with base URL and authentication headers. 166 | * @protected 167 | */ 168 | protected runtimeClient: AxiosInstance 169 | 170 | /** 171 | * OpenAI client instance. 172 | * Lazily initialized when needed using the provided API key. 173 | * @protected 174 | */ 175 | protected _openai?: OpenAI 176 | 177 | /** 178 | * Getter that converts the agent's tools into OpenAI function calling format. 179 | * Used when making chat completion requests to OpenAI. 180 | * @private 181 | * @returns Array of ChatCompletionTool objects 182 | */ 183 | private get openAiTools(): ChatCompletionTool[] { 184 | return this.tools.map(tool => ({ 185 | type: 'function', 186 | function: { 187 | name: tool.name, 188 | description: tool.description, 189 | parameters: zodToJsonSchema(tool.schema) 190 | } 191 | })) as ChatCompletionTool[] 192 | } 193 | 194 | /** 195 | * Getter that provides access to the OpenAI client instance. 196 | * Lazily initializes the client with the API key from options or environment. 197 | * @private 198 | * @throws {Error} If no OpenAI API key is available 199 | * @returns {OpenAI} The OpenAI client instance 200 | */ 201 | private get openai(): OpenAI { 202 | if (!this._openai) { 203 | const apiKey = this.options.openaiApiKey || process.env.OPENAI_API_KEY 204 | if (!apiKey) { 205 | throw new Error( 206 | 'OpenAI API key is required for process(). Please provide it in options or set OPENAI_API_KEY environment variable.' 207 | ) 208 | } 209 | this._openai = new OpenAI({ apiKey }) 210 | } 211 | return this._openai 212 | } 213 | 214 | /** 215 | * Creates a new Agent instance. 216 | * Sets up the Express application, middleware, and routes. 217 | * Initializes API clients with appropriate authentication. 218 | * 219 | * @param {AgentOptions} options - Configuration options for the agent 220 | * @throws {Error} If OpenServ API key is not provided in options or environment 221 | */ 222 | constructor(private options: AgentOptions) { 223 | this.app = express() 224 | this.router = AsyncRouter() 225 | this.port = this.options.port || DEFAULT_PORT 226 | this.systemPrompt = this.options.systemPrompt 227 | this.apiKey = this.options.apiKey || process.env.OPENSERV_API_KEY || '' 228 | 229 | if (!this.apiKey) { 230 | throw new Error( 231 | 'OpenServ API key is required. Please provide it in options or set OPENSERV_API_KEY environment variable.' 232 | ) 233 | } 234 | 235 | // Initialize API client 236 | this.apiClient = axios.create({ 237 | baseURL: PLATFORM_URL, 238 | headers: { 239 | 'Content-Type': 'application/json', 240 | 'x-openserv-key': this.apiKey 241 | } 242 | }) 243 | 244 | // Initialize runtime client 245 | this.runtimeClient = axios.create({ 246 | baseURL: `${RUNTIME_URL}/runtime`, 247 | headers: { 248 | 'Content-Type': 'application/json', 249 | 'x-openserv-key': this.apiKey 250 | } 251 | }) 252 | 253 | this.app.use(express.json()) 254 | this.app.use(express.urlencoded({ extended: false })) 255 | this.app.use(hpp()) 256 | this.app.use(helmet()) 257 | this.app.use(compression()) 258 | 259 | if (AUTH_TOKEN) { 260 | this.app.use(authTokenMiddleware) 261 | } else { 262 | logger.warn('OPENSERV_AUTH_TOKEN is not set. All requests will be allowed.') 263 | } 264 | 265 | this.setupRoutes() 266 | } 267 | 268 | /** 269 | * Adds a single capability (tool) to the agent. 270 | * Each capability must have a unique name and defines a function that can be called via the API. 271 | * 272 | * @template S - The Zod schema type for the capability's parameters 273 | * @param {Object} capability - The capability configuration 274 | * @param {string} capability.name - Unique name for the capability 275 | * @param {string} capability.description - Description of what the capability does 276 | * @param {S} capability.schema - Zod schema defining the capability's parameters 277 | * @param {Function} capability.run - Function that implements the capability's behavior 278 | * @param {Object} capability.run.params - Parameters for the run function 279 | * @param {z.infer} capability.run.params.args - Validated arguments matching the schema 280 | * @param {z.infer} [capability.run.params.action] - Optional action context 281 | * @param {ChatCompletionMessageParam[]} capability.run.messages - Chat message history 282 | * @returns {this} The agent instance for method chaining 283 | * @throws {Error} If a capability with the same name already exists 284 | */ 285 | addCapability({ 286 | name, 287 | description, 288 | schema, 289 | run 290 | }: { 291 | name: string 292 | description: string 293 | schema: S 294 | run( 295 | this: Agent, 296 | params: { args: z.infer; action?: z.infer }, 297 | messages: ChatCompletionMessageParam[] 298 | ): string | Promise 299 | }): this { 300 | // Validate tool name uniqueness 301 | if (this.tools.some(tool => tool.name === name)) { 302 | throw new Error(`Tool with name "${name}" already exists`) 303 | } 304 | // Type assertion through unknown for safe conversion between compatible generic types 305 | this.tools.push( 306 | new Capability(name, description, schema, run) as unknown as Capability 307 | ) 308 | return this 309 | } 310 | 311 | /** 312 | * Adds multiple capabilities (tools) to the agent at once. 313 | * Each capability must have a unique name and not conflict with existing capabilities. 314 | * 315 | * @template T - Tuple of Zod schema types for the capabilities' parameters 316 | * @param {Object} capabilities - Array of capability configurations 317 | * @param {string} capabilities[].name - Unique name for each capability 318 | * @param {string} capabilities[].description - Description of what each capability does 319 | * @param {T[number]} capabilities[].schema - Zod schema defining each capability's parameters 320 | * @param {Function} capabilities[].run - Function that implements each capability's behavior 321 | * @returns {this} The agent instance for method chaining 322 | * @throws {Error} If any capability has a name that already exists 323 | */ 324 | addCapabilities(capabilities: { 325 | [K in keyof T]: { 326 | name: string 327 | description: string 328 | schema: T[K] 329 | run( 330 | this: Agent, 331 | params: { args: z.infer; action?: z.infer }, 332 | messages: ChatCompletionMessageParam[] 333 | ): string | Promise 334 | } 335 | }): this { 336 | for (const capability of capabilities) { 337 | this.addCapability(capability) 338 | } 339 | return this 340 | } 341 | 342 | /** 343 | * Gets files in a workspace. 344 | * 345 | * @param {GetFilesParams} params - Parameters for the file retrieval 346 | * @param {number} params.workspaceId - ID of the workspace to get files from 347 | * @returns {Promise} The files in the workspace 348 | */ 349 | async getFiles(params: GetFilesParams) { 350 | const response = await this.apiClient.get(`/workspaces/${params.workspaceId}/files`) 351 | return response.data 352 | } 353 | 354 | /** 355 | * Get all secrets for an agent in a workspace. 356 | * 357 | * @param {GetSecretsParams} params - Parameters for the secrets retrieval 358 | * @returns {Promise} List of agent secrets. 359 | */ 360 | async getSecrets(params: GetSecretsParams) { 361 | const response = await this.apiClient.get(`/workspaces/${params.workspaceId}/agent-secrets`) 362 | return response.data 363 | } 364 | 365 | /** 366 | * Get the value of a secret for an agent in a workspace 367 | * 368 | * @param {GetSecretValueParams} params - Parameters for the secret value retrieval 369 | * @returns {Promise} The value of the secret. 370 | */ 371 | async getSecretValue(params: GetSecretValueParams): Promise { 372 | const response = await this.apiClient.get( 373 | `/workspaces/${params.workspaceId}/agent-secrets/${params.secretId}/value` 374 | ) 375 | return response.data 376 | } 377 | 378 | /** 379 | * Uploads a file to a workspace. 380 | * 381 | * @param {UploadFileParams} params - Parameters for the file upload 382 | * @param {number} params.workspaceId - ID of the workspace to upload to 383 | * @param {string} params.path - Path where the file should be stored 384 | * @param {number[]|number|null} [params.taskIds] - Optional task IDs to associate with the file 385 | * @param {boolean} [params.skipSummarizer] - Whether to skip file summarization 386 | * @param {Buffer|string} params.file - The file content to upload 387 | * @returns {Promise} The uploaded file details 388 | */ 389 | async uploadFile(params: UploadFileParams) { 390 | const formData = new FormData() 391 | formData.append('path', params.path) 392 | if (params.taskIds) { 393 | formData.append('taskIds', JSON.stringify(params.taskIds)) 394 | } 395 | if (params.skipSummarizer !== undefined) { 396 | formData.append('skipSummarizer', params.skipSummarizer.toString()) 397 | } 398 | 399 | // Convert Buffer or string to Blob for FormData 400 | const fileBlob = 401 | params.file instanceof Buffer 402 | ? new Blob([params.file]) 403 | : new Blob([params.file], { type: 'text/plain' }) 404 | formData.append('file', fileBlob) 405 | 406 | const response = await this.apiClient.post(`/workspaces/${params.workspaceId}/file`, formData, { 407 | headers: { 408 | 'Content-Type': 'multipart/form-data' 409 | } 410 | }) 411 | return response.data 412 | } 413 | 414 | /** 415 | * Deletes a file from a workspace. 416 | * 417 | * @param {DeleteFileParams} params - Parameters for the file deletion 418 | * @param {number} params.workspaceId - ID of the workspace containing the file 419 | * @param {number} params.fileId - ID of the file to delete 420 | * @returns {Promise} A success message confirming the file was deleted 421 | */ 422 | async deleteFile(params: DeleteFileParams): Promise { 423 | const response = await this.apiClient.delete( 424 | `/workspaces/${params.workspaceId}/files/${params.fileId}` 425 | ) 426 | return response.data 427 | } 428 | 429 | /** 430 | * Marks a task as errored. 431 | * 432 | * @param {MarkTaskAsErroredParams} params - Parameters for marking the task as errored 433 | * @param {number} params.workspaceId - ID of the workspace containing the task 434 | * @param {number} params.taskId - ID of the task to mark as errored 435 | * @param {string} params.error - Error message describing what went wrong 436 | * @returns {Promise} The updated task details 437 | */ 438 | async markTaskAsErrored(params: MarkTaskAsErroredParams) { 439 | const response = await this.apiClient.post( 440 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/error`, 441 | { 442 | error: params.error 443 | } 444 | ) 445 | return response.data 446 | } 447 | 448 | /** 449 | * Completes a task with the specified output. 450 | * 451 | * @param {CompleteTaskParams} params - Parameters for completing the task 452 | * @param {number} params.workspaceId - ID of the workspace containing the task 453 | * @param {number} params.taskId - ID of the task to complete 454 | * @param {string} params.output - Output or result of the completed task 455 | * @returns {Promise} The completed task details 456 | */ 457 | async completeTask(params: CompleteTaskParams) { 458 | const response = await this.apiClient.put( 459 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/complete`, 460 | { 461 | output: params.output 462 | } 463 | ) 464 | return response.data 465 | } 466 | 467 | /** 468 | * Sends a chat message from the agent. 469 | * 470 | * @param {SendChatMessageParams} params - Parameters for sending the chat message 471 | * @param {number} params.workspaceId - ID of the workspace where the chat is happening 472 | * @param {number} params.agentId - ID of the agent sending the message 473 | * @param {string} params.message - Content of the message to send 474 | * @returns {Promise} The sent message details 475 | */ 476 | async sendChatMessage(params: SendChatMessageParams) { 477 | const response = await this.apiClient.post( 478 | `/workspaces/${params.workspaceId}/agent-chat/${params.agentId}/message`, 479 | { 480 | message: params.message 481 | } 482 | ) 483 | return response.data 484 | } 485 | 486 | /** 487 | * Gets detailed information about a specific task. 488 | * 489 | * @param {GetTaskDetailParams} params - Parameters for getting task details 490 | * @param {number} params.workspaceId - ID of the workspace containing the task 491 | * @param {number} params.taskId - ID of the task to get details for 492 | * @returns {Promise} The detailed task information 493 | */ 494 | async getTaskDetail(params: GetTaskDetailParams) { 495 | const response = await this.apiClient.get( 496 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/detail` 497 | ) 498 | return response.data 499 | } 500 | 501 | /** 502 | * Gets a list of agents in a workspace. 503 | * 504 | * @param {GetAgentsParams} params - Parameters for getting agents 505 | * @param {number} params.workspaceId - ID of the workspace to get agents from 506 | * @returns {Promise} List of agents in the workspace 507 | */ 508 | async getAgents(params: GetAgentsParams) { 509 | const response = await this.apiClient.get(`/workspaces/${params.workspaceId}/agents`) 510 | return response.data 511 | } 512 | 513 | /** 514 | * Gets a list of tasks in a workspace. 515 | * 516 | * @param {GetTasksParams} params - Parameters for getting tasks 517 | * @param {number} params.workspaceId - ID of the workspace to get tasks from 518 | * @returns {Promise} List of tasks in the workspace 519 | */ 520 | async getTasks(params: GetTasksParams) { 521 | const response = await this.apiClient.get(`/workspaces/${params.workspaceId}/tasks`) 522 | return response.data 523 | } 524 | 525 | /** 526 | * Gets a list of tasks in a workspace. 527 | * 528 | * @param {GetChatMessagesParams} params - Parameters for getting chat messages 529 | * @param {number} params.workspaceId - ID of the workspace to get chat messages from 530 | * @param {number} params.agentId - ID of the agent to get chat messages from 531 | * @returns {Promise} List of chat messages 532 | */ 533 | async getChatMessages(params: GetChatMessagesParams) { 534 | const response = await this.apiClient.get( 535 | `/workspaces/${params.workspaceId}/agent-chat/${params.agentId}/messages` 536 | ) 537 | return response.data as AgentChatMessagesResponse 538 | } 539 | 540 | /** 541 | * Creates a new task in a workspace. 542 | * 543 | * @param {CreateTaskParams} params - Parameters for creating the task 544 | * @param {number} params.workspaceId - ID of the workspace to create the task in 545 | * @param {number} params.assignee - ID of the agent to assign the task to 546 | * @param {string} params.description - Short description of the task 547 | * @param {string} params.body - Detailed body/content of the task 548 | * @param {string} params.input - Input data for the task 549 | * @param {string} params.expectedOutput - Expected output format or content 550 | * @param {number[]} params.dependencies - IDs of tasks that this task depends on 551 | * @returns {Promise} The created task details 552 | */ 553 | async createTask(params: CreateTaskParams) { 554 | const response = await this.apiClient.post(`/workspaces/${params.workspaceId}/task`, { 555 | assignee: params.assignee, 556 | description: params.description, 557 | body: params.body, 558 | input: params.input, 559 | expectedOutput: params.expectedOutput, 560 | dependencies: params.dependencies 561 | }) 562 | return response.data 563 | } 564 | 565 | /** 566 | * Adds a log entry to a task. 567 | * 568 | * @param {AddLogToTaskParams} params - Parameters for adding the log 569 | * @param {number} params.workspaceId - ID of the workspace containing the task 570 | * @param {number} params.taskId - ID of the task to add the log to 571 | * @param {'info'|'warning'|'error'} params.severity - Severity level of the log 572 | * @param {'text'|'openai-message'} params.type - Type of log entry 573 | * @param {string|object} params.body - Content of the log entry 574 | * @returns {Promise} The created log entry details 575 | */ 576 | async addLogToTask(params: AddLogToTaskParams) { 577 | const response = await this.apiClient.post( 578 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/log`, 579 | { 580 | severity: params.severity, 581 | type: params.type, 582 | body: params.body 583 | } 584 | ) 585 | return response.data 586 | } 587 | 588 | /** 589 | * Requests human assistance for a task. 590 | * 591 | * @param {RequestHumanAssistanceParams} params - Parameters for requesting assistance 592 | * @param {number} params.workspaceId - ID of the workspace containing the task 593 | * @param {number} params.taskId - ID of the task needing assistance 594 | * @param {'text'|'project-manager-plan-review'} params.type - Type of assistance needed 595 | * @param {string|object} params.question - Question or request for the human 596 | * @param {object} [params.agentDump] - Optional agent state/context information 597 | * @returns {Promise} The created assistance request details 598 | */ 599 | async requestHumanAssistance(params: RequestHumanAssistanceParams) { 600 | let question = params.question 601 | 602 | if (typeof question === 'string') { 603 | question = { 604 | type: 'text', 605 | question 606 | } 607 | } else { 608 | question = { 609 | type: 'json', 610 | ...question 611 | } 612 | } 613 | 614 | const response = await this.apiClient.post( 615 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/human-assistance`, 616 | { 617 | type: params.type, 618 | question, 619 | agentDump: params.agentDump 620 | } 621 | ) 622 | return response.data 623 | } 624 | 625 | /** 626 | * Updates the status of a task. 627 | * 628 | * @param {UpdateTaskStatusParams} params - Parameters for updating the status 629 | * @param {number} params.workspaceId - ID of the workspace containing the task 630 | * @param {number} params.taskId - ID of the task to update 631 | * @param {TaskStatus} params.status - New status for the task 632 | * @returns {Promise} The updated task details 633 | */ 634 | async updateTaskStatus(params: UpdateTaskStatusParams) { 635 | const response = await this.apiClient.put( 636 | `/workspaces/${params.workspaceId}/tasks/${params.taskId}/status`, 637 | { 638 | status: params.status 639 | } 640 | ) 641 | return response.data 642 | } 643 | 644 | /** 645 | * Processes a conversation with OpenAI, handling tool calls iteratively until completion. 646 | * 647 | * @param {ProcessParams} params - Parameters for processing the conversation 648 | * @param {ChatCompletionMessageParam[]} params.messages - The conversation history 649 | * @returns {Promise} The final response from OpenAI 650 | * @throws {Error} If no response is received from OpenAI or max iterations are reached 651 | */ 652 | async process({ messages }: ProcessParams): Promise { 653 | try { 654 | const apiKey = this.options.openaiApiKey || process.env.OPENAI_API_KEY 655 | if (!apiKey) { 656 | throw new Error( 657 | 'OpenAI API key is required for process(). Please provide it in options or set OPENAI_API_KEY environment variable.' 658 | ) 659 | } 660 | 661 | const currentMessages = [...messages] 662 | 663 | if (!currentMessages.find(m => m.content === this.systemPrompt)) { 664 | currentMessages.unshift({ 665 | role: 'system', 666 | content: this.systemPrompt 667 | }) 668 | } 669 | 670 | let completion: ChatCompletion | null = null 671 | let iterationCount = 0 672 | const MAX_ITERATIONS = 10 673 | 674 | while (iterationCount < MAX_ITERATIONS) { 675 | completion = await this.openai.chat.completions.create({ 676 | model: 'gpt-4o', 677 | messages: currentMessages, 678 | tools: this.tools.length ? this.openAiTools : undefined 679 | }) 680 | 681 | if (!completion.choices?.length || !completion.choices[0]?.message) { 682 | throw new Error('No response from OpenAI') 683 | } 684 | 685 | const lastMessage = completion.choices[0].message 686 | 687 | // If there are no tool calls, we're done 688 | if (!lastMessage.tool_calls?.length) { 689 | return completion 690 | } 691 | 692 | // Process each tool call 693 | const toolResults = await Promise.all( 694 | lastMessage.tool_calls.map(async toolCall => { 695 | if (!toolCall.function) { 696 | throw new Error('Tool call function is missing') 697 | } 698 | const { name, arguments: args } = toolCall.function 699 | const parsedArgs = JSON.parse(args) 700 | 701 | try { 702 | // Find the tool in our tools array 703 | const tool = this.tools.find(t => t.name === name) 704 | if (!tool) { 705 | throw new Error(`Tool "${name}" not found`) 706 | } 707 | 708 | // Call the tool's run method with the parsed arguments and bind this 709 | const result = await tool.run.bind(this)({ args: parsedArgs }, currentMessages) 710 | return { 711 | role: 'tool' as const, 712 | content: JSON.stringify(result), 713 | tool_call_id: toolCall.id 714 | } 715 | } catch (error) { 716 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' 717 | this.handleError(error instanceof Error ? error : new Error(errorMessage), { 718 | toolCall, 719 | context: 'tool_execution' 720 | }) 721 | return { 722 | role: 'tool' as const, 723 | content: JSON.stringify({ error: errorMessage }), 724 | tool_call_id: toolCall.id 725 | } 726 | } 727 | }) 728 | ) 729 | 730 | // Add the assistant's message and tool results to the conversation 731 | currentMessages.push(lastMessage, ...toolResults) 732 | iterationCount++ 733 | } 734 | 735 | throw new Error('Max iterations reached without completion') 736 | } catch (error) { 737 | this.handleError(error instanceof Error ? error : new Error(String(error)), { 738 | context: 'process' 739 | }) 740 | throw error 741 | } 742 | } 743 | 744 | /** 745 | * Handle a task execution request 746 | * This method can be overridden by extending classes to customize task handling 747 | * @protected 748 | */ 749 | protected async doTask(action: z.infer) { 750 | const messages: ChatCompletionMessageParam[] = [ 751 | { 752 | role: 'system', 753 | content: this.systemPrompt 754 | } 755 | ] 756 | 757 | if (action.task?.description) { 758 | messages.push({ 759 | role: 'user', 760 | content: action.task.description 761 | }) 762 | } 763 | 764 | try { 765 | await this.runtimeClient.post('/execute', { 766 | tools: this.tools.map(convertToolToJsonSchema), 767 | messages, 768 | action 769 | }) 770 | } catch (error) { 771 | this.handleError(error instanceof Error ? error : new Error(String(error)), { 772 | action, 773 | context: 'do_task' 774 | }) 775 | } 776 | } 777 | 778 | /** 779 | * Handle a chat message response request 780 | * This method can be overridden by extending classes to customize chat handling 781 | * @protected 782 | */ 783 | protected async respondToChat(action: z.infer) { 784 | const messages: ChatCompletionMessageParam[] = [ 785 | { 786 | role: 'system', 787 | content: this.systemPrompt 788 | } 789 | ] 790 | 791 | if (action.messages) { 792 | for (const msg of action.messages) { 793 | messages.push({ 794 | role: msg.author === 'user' ? 'user' : 'assistant', 795 | content: msg.message 796 | }) 797 | } 798 | } 799 | 800 | try { 801 | await this.runtimeClient.post('/chat', { 802 | tools: this.tools.map(convertToolToJsonSchema), 803 | messages, 804 | action 805 | }) 806 | } catch (error) { 807 | this.handleError(error instanceof Error ? error : new Error(String(error)), { 808 | action, 809 | context: 'respond_to_chat' 810 | }) 811 | } 812 | } 813 | 814 | /** 815 | * Handles execution of a specific tool/capability. 816 | * 817 | * @param {Object} req - The request object 818 | * @param {Object} req.params - Request parameters 819 | * @param {string} req.params.toolName - Name of the tool to execute 820 | * @param {Object} req.body - Request body 821 | * @param {z.infer} [req.body.args] - Arguments for the tool 822 | * @param {z.infer} [req.body.action] - Action context 823 | * @param {ChatCompletionMessageParam[]} [req.body.messages] - Message history 824 | * @returns {Promise<{result: string}>} The result of the tool execution 825 | * @throws {BadRequest} If tool name is missing or tool is not found 826 | * @throws {Error} If tool execution fails 827 | */ 828 | async handleToolRoute(req: { 829 | params: { toolName: string } 830 | body: { 831 | args?: z.infer 832 | action?: z.infer 833 | messages?: ChatCompletionMessageParam[] 834 | } 835 | }) { 836 | try { 837 | if (!('toolName' in req.params)) { 838 | throw new BadRequest('Tool name is required') 839 | } 840 | 841 | const tool = this.tools.find(t => t.name === req.params.toolName) 842 | if (!tool) { 843 | throw new BadRequest(`Tool "${req.params.toolName}" not found`) 844 | } 845 | 846 | const args = await tool.schema.parseAsync(req.body?.args) 847 | const messages = req.body.messages || [] 848 | const result = await tool.run.call(this, { args, action: req.body.action }, messages) 849 | return { result } 850 | } catch (error) { 851 | this.handleError(error instanceof Error ? error : new Error(String(error)), { 852 | request: req, 853 | context: 'handle_tool_route' 854 | }) 855 | 856 | throw error 857 | } 858 | } 859 | 860 | /** 861 | * Handles the root route for task execution and chat message responses. 862 | * 863 | * @param {Object} req - The request object 864 | * @param {unknown} req.body - Request body to be parsed as an action 865 | * @returns {Promise} 866 | * @throws {Error} If action type is invalid 867 | */ 868 | async handleRootRoute(req: { body: unknown }) { 869 | try { 870 | const action = await actionSchema.parseAsync(req.body) 871 | if (action.type === 'do-task') { 872 | this.doTask(action) 873 | } else if (action.type === 'respond-chat-message') { 874 | this.respondToChat(action) 875 | } else throw new Error('Invalid action type') 876 | } catch (error) { 877 | this.handleError(error instanceof Error ? error : new Error(String(error)), { 878 | request: req, 879 | context: 'handle_root_route' 880 | }) 881 | } 882 | } 883 | 884 | /** 885 | * Sets up the Express routes for the agent's HTTP server. 886 | * Configures health check endpoint and routes for tool execution. 887 | * @private 888 | */ 889 | private setupRoutes() { 890 | this.router.get('/health', async (_req: express.Request, res: express.Response) => { 891 | res.status(200).json({ status: 'ok', uptime: process.uptime() }) 892 | }) 893 | 894 | this.router.post('/', async (req: express.Request) => { 895 | return this.handleRootRoute({ body: req.body }) 896 | }) 897 | 898 | this.router.post('/tools/:toolName', async (req: express.Request) => { 899 | const { toolName } = req.params 900 | if (!toolName) { 901 | throw new BadRequest('Tool name is required') 902 | } 903 | return this.handleToolRoute({ 904 | params: { toolName }, 905 | body: req.body 906 | }) 907 | }) 908 | 909 | this.app.use('/', this.router) 910 | } 911 | 912 | /** 913 | * Starts the agent's HTTP server. 914 | * 915 | * @returns {Promise} Resolves when the server has started 916 | * @throws {Error} If server fails to start 917 | */ 918 | async start() { 919 | return new Promise((resolve, reject) => { 920 | this.server = this.app.listen(this.port, () => { 921 | logger.info(`Agent server started on port ${this.port}`) 922 | resolve() 923 | }) 924 | this.server.on('error', reject) 925 | }) 926 | } 927 | 928 | /** 929 | * Stops the agent's HTTP server. 930 | * 931 | * @returns {Promise} Resolves when the server has stopped 932 | */ 933 | async stop() { 934 | if (!this.server) return 935 | 936 | return new Promise(resolve => { 937 | this.server?.close(() => resolve()) 938 | }) 939 | } 940 | 941 | /** 942 | * Default error handler that logs the error 943 | * @private 944 | */ 945 | private handleError(error: Error, context?: Record) { 946 | const handler = 947 | this.options.onError ?? 948 | ((err, ctx) => logger.error({ error: err, ...ctx }, 'Error in agent operation')) 949 | handler(error, context) 950 | } 951 | 952 | /** 953 | * Calls an integration endpoint through the OpenServ platform. 954 | * This method allows agents to interact with external services and APIs that are integrated with OpenServ. 955 | * 956 | * @param {IntegrationCallRequest} integration - The integration request parameters 957 | * @param {number} integration.workspaceId - ID of the workspace where the integration is configured 958 | * @param {string} integration.integrationId - ID of the integration to call 959 | * @param {Object} integration.details - Details of the integration call 960 | * @param {string} integration.details.endpoint - The endpoint to call on the integration 961 | * @param {string} integration.details.method - The HTTP method to use (GET, POST, etc.) 962 | * @param {Object} [integration.details.data] - Optional data payload for the request 963 | * @returns {Promise} The response from the integration endpoint 964 | * @throws {Error} If the integration call fails 965 | */ 966 | async callIntegration(integration: IntegrationCallRequest) { 967 | const response = await this.apiClient.post( 968 | `/workspaces/${integration.workspaceId}/integration/${integration.integrationId}/proxy`, 969 | integration.details 970 | ) 971 | 972 | return response.data 973 | } 974 | } 975 | 976 | function convertToolToJsonSchema(tool: Capability) { 977 | return { 978 | name: tool.name, 979 | description: tool.description, 980 | schema: zodToJsonSchema(tool.schema) 981 | } 982 | } 983 | -------------------------------------------------------------------------------- /src/capability.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' 3 | import type { CapabilityFuncParams } from './types' 4 | import type { Agent } from './agent' 5 | 6 | export class Capability { 7 | constructor( 8 | public readonly name: string, 9 | public readonly description: string, 10 | public readonly schema: Schema, 11 | public readonly run: ( 12 | this: Agent, 13 | params: CapabilityFuncParams, 14 | messages: ChatCompletionMessageParam[] 15 | ) => string | Promise 16 | ) {} 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Agent } from './agent' 2 | export type { AgentOptions } from './agent' 3 | export { Capability } from './capability' 4 | 5 | export type { 6 | TaskStatus, 7 | GetFilesParams, 8 | UploadFileParams, 9 | DeleteFileParams, 10 | MarkTaskAsErroredParams, 11 | CompleteTaskParams, 12 | SendChatMessageParams, 13 | GetTaskDetailParams, 14 | GetAgentsParams, 15 | GetTasksParams, 16 | CreateTaskParams, 17 | AddLogToTaskParams, 18 | RequestHumanAssistanceParams, 19 | UpdateTaskStatusParams, 20 | ProcessParams, 21 | CapabilityFuncParams, 22 | GetChatMessagesParams 23 | } from './types' 24 | 25 | export { 26 | actionSchema, 27 | doTaskActionSchema, 28 | respondChatMessageActionSchema, 29 | taskStatusSchema, 30 | agentKind 31 | } from './types' 32 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | export const createLogger = () => 4 | pino({ 5 | name: 'openserv-agent', 6 | level: process.env.LOG_LEVEL || 'info', 7 | transport: { 8 | target: 'pino-pretty', 9 | options: { 10 | colorize: true 11 | } 12 | } 13 | }) 14 | 15 | export const logger = createLogger() 16 | -------------------------------------------------------------------------------- /src/openai-tools.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "function", 4 | "function": { 5 | "name": "getWorkspace", 6 | "description": "Get workspace details", 7 | "parameters": { 8 | "type": "object", 9 | "properties": { 10 | "workspaceId": { 11 | "type": "number", 12 | "description": "The ID of the workspace" 13 | } 14 | }, 15 | "required": ["workspaceId"] 16 | } 17 | } 18 | }, 19 | { 20 | "type": "function", 21 | "function": { 22 | "name": "getFiles", 23 | "description": "Get files in workspace", 24 | "parameters": { 25 | "type": "object", 26 | "properties": { 27 | "workspaceId": { 28 | "type": "number", 29 | "description": "The ID of the workspace" 30 | } 31 | }, 32 | "required": ["workspaceId"] 33 | } 34 | } 35 | }, 36 | { 37 | "type": "function", 38 | "function": { 39 | "name": "uploadFile", 40 | "description": "Upload a file to workspace", 41 | "parameters": { 42 | "type": "object", 43 | "properties": { 44 | "workspaceId": { 45 | "type": "number", 46 | "description": "The ID of the workspace" 47 | }, 48 | "path": { 49 | "type": "string", 50 | "description": "The path where the file should be uploaded" 51 | }, 52 | "taskIds": { 53 | "type": "array", 54 | "items": { 55 | "type": "number" 56 | }, 57 | "description": "Optional task IDs to associate with the file" 58 | }, 59 | "skipSummarizer": { 60 | "type": "boolean", 61 | "description": "Whether to skip the summarizer" 62 | }, 63 | "file": { 64 | "type": "string", 65 | "description": "The file content as a string" 66 | } 67 | }, 68 | "required": ["workspaceId", "path", "file"] 69 | } 70 | } 71 | }, 72 | { 73 | "type": "function", 74 | "function": { 75 | "name": "deleteFile", 76 | "description": "Delete a file from workspace", 77 | "parameters": { 78 | "type": "object", 79 | "properties": { 80 | "workspaceId": { 81 | "type": "number", 82 | "description": "The ID of the workspace" 83 | }, 84 | "fileId": { 85 | "type": "number", 86 | "description": "The ID of the file to delete" 87 | } 88 | }, 89 | "required": ["workspaceId", "fileId"] 90 | } 91 | } 92 | }, 93 | { 94 | "type": "function", 95 | "function": { 96 | "name": "markTaskAsErrored", 97 | "description": "Mark a task as errored", 98 | "parameters": { 99 | "type": "object", 100 | "properties": { 101 | "workspaceId": { 102 | "type": "number", 103 | "description": "The ID of the workspace" 104 | }, 105 | "taskId": { 106 | "type": "number", 107 | "description": "The ID of the task" 108 | }, 109 | "error": { 110 | "type": "string", 111 | "description": "The error message" 112 | } 113 | }, 114 | "required": ["workspaceId", "taskId", "error"] 115 | } 116 | } 117 | }, 118 | { 119 | "type": "function", 120 | "function": { 121 | "name": "completeTask", 122 | "description": "Complete a task", 123 | "parameters": { 124 | "type": "object", 125 | "properties": { 126 | "workspaceId": { 127 | "type": "number", 128 | "description": "The ID of the workspace" 129 | }, 130 | "taskId": { 131 | "type": "number", 132 | "description": "The ID of the task" 133 | }, 134 | "output": { 135 | "type": "string", 136 | "description": "The task output" 137 | } 138 | }, 139 | "required": ["workspaceId", "taskId", "output"] 140 | } 141 | } 142 | }, 143 | { 144 | "type": "function", 145 | "function": { 146 | "name": "sendChatMessage", 147 | "description": "Send a chat message", 148 | "parameters": { 149 | "type": "object", 150 | "properties": { 151 | "workspaceId": { 152 | "type": "number", 153 | "description": "The ID of the workspace" 154 | }, 155 | "message": { 156 | "type": "string", 157 | "description": "The chat message" 158 | } 159 | }, 160 | "required": ["workspaceId", "message"] 161 | } 162 | } 163 | }, 164 | { 165 | "type": "function", 166 | "function": { 167 | "name": "getChatMessages", 168 | "description": "Get chat messages", 169 | "parameters": { 170 | "type": "object", 171 | "properties": { 172 | "workspaceId": { 173 | "type": "number", 174 | "description": "The ID of the workspace" 175 | }, 176 | "agentId": { 177 | "type": "number", 178 | "description": "The ID of the agent" 179 | } 180 | }, 181 | "required": ["workspaceId", "agentId"] 182 | } 183 | } 184 | }, 185 | { 186 | "type": "function", 187 | "function": { 188 | "name": "getTaskDetail", 189 | "description": "Get task details", 190 | "parameters": { 191 | "type": "object", 192 | "properties": { 193 | "workspaceId": { 194 | "type": "number", 195 | "description": "The ID of the workspace" 196 | }, 197 | "taskId": { 198 | "type": "number", 199 | "description": "The ID of the task" 200 | } 201 | }, 202 | "required": ["workspaceId", "taskId"] 203 | } 204 | } 205 | }, 206 | { 207 | "type": "function", 208 | "function": { 209 | "name": "getAgents", 210 | "description": "Get agents in workspace", 211 | "parameters": { 212 | "type": "object", 213 | "properties": { 214 | "workspaceId": { 215 | "type": "number", 216 | "description": "The ID of the workspace" 217 | } 218 | }, 219 | "required": ["workspaceId"] 220 | } 221 | } 222 | }, 223 | { 224 | "type": "function", 225 | "function": { 226 | "name": "getTasks", 227 | "description": "Get tasks in workspace", 228 | "parameters": { 229 | "type": "object", 230 | "properties": { 231 | "workspaceId": { 232 | "type": "number", 233 | "description": "The ID of the workspace" 234 | } 235 | }, 236 | "required": ["workspaceId"] 237 | } 238 | } 239 | }, 240 | { 241 | "type": "function", 242 | "function": { 243 | "name": "createTask", 244 | "description": "Create a new task", 245 | "parameters": { 246 | "type": "object", 247 | "properties": { 248 | "workspaceId": { 249 | "type": "number", 250 | "description": "The ID of the workspace" 251 | }, 252 | "assignee": { 253 | "type": "number", 254 | "description": "The ID of the assignee" 255 | }, 256 | "description": { 257 | "type": "string", 258 | "description": "Task description" 259 | }, 260 | "body": { 261 | "type": "string", 262 | "description": "Task body" 263 | }, 264 | "input": { 265 | "type": "string", 266 | "description": "Task input" 267 | }, 268 | "expectedOutput": { 269 | "type": "string", 270 | "description": "Expected task output" 271 | }, 272 | "dependencies": { 273 | "type": "array", 274 | "items": { 275 | "type": "number" 276 | }, 277 | "description": "Task dependencies" 278 | } 279 | }, 280 | "required": [ 281 | "workspaceId", 282 | "assignee", 283 | "description", 284 | "body", 285 | "input", 286 | "expectedOutput", 287 | "dependencies" 288 | ] 289 | } 290 | } 291 | }, 292 | { 293 | "type": "function", 294 | "function": { 295 | "name": "addLogToTask", 296 | "description": "Add a log to a task", 297 | "parameters": { 298 | "type": "object", 299 | "properties": { 300 | "workspaceId": { 301 | "type": "number", 302 | "description": "The ID of the workspace" 303 | }, 304 | "taskId": { 305 | "type": "number", 306 | "description": "The ID of the task" 307 | }, 308 | "severity": { 309 | "type": "string", 310 | "enum": ["info", "warning", "error"], 311 | "description": "Log severity" 312 | }, 313 | "type": { 314 | "type": "string", 315 | "enum": ["text", "openai-message"], 316 | "description": "Log type" 317 | }, 318 | "body": { 319 | "type": "string", 320 | "description": "Log body" 321 | } 322 | }, 323 | "required": ["workspaceId", "taskId", "severity", "type", "body"] 324 | } 325 | } 326 | }, 327 | { 328 | "type": "function", 329 | "function": { 330 | "name": "requestHumanAssistance", 331 | "description": "Request human assistance", 332 | "parameters": { 333 | "type": "object", 334 | "properties": { 335 | "workspaceId": { 336 | "type": "number", 337 | "description": "The ID of the workspace" 338 | }, 339 | "taskId": { 340 | "type": "number", 341 | "description": "The ID of the task" 342 | }, 343 | "type": { 344 | "type": "string", 345 | "enum": ["text", "project-manager-plan-review"], 346 | "description": "Type of assistance needed" 347 | }, 348 | "question": { 349 | "type": "string", 350 | "description": "The question or request for assistance" 351 | }, 352 | "agentDump": { 353 | "type": "object", 354 | "description": "Optional agent state dump" 355 | } 356 | }, 357 | "required": ["workspaceId", "taskId", "type", "question"] 358 | } 359 | } 360 | }, 361 | { 362 | "type": "function", 363 | "function": { 364 | "name": "updateTaskStatus", 365 | "description": "Update task status", 366 | "parameters": { 367 | "type": "object", 368 | "properties": { 369 | "workspaceId": { 370 | "type": "number", 371 | "description": "The ID of the workspace" 372 | }, 373 | "taskId": { 374 | "type": "number", 375 | "description": "The ID of the task" 376 | }, 377 | "status": { 378 | "type": "string", 379 | "enum": [ 380 | "to-do", 381 | "in-progress", 382 | "human-assistance-required", 383 | "error", 384 | "done", 385 | "cancelled" 386 | ], 387 | "description": "The new task status" 388 | } 389 | }, 390 | "required": ["workspaceId", "taskId", "status"] 391 | } 392 | } 393 | } 394 | ] 395 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi' 3 | import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' 4 | 5 | extendZodWithOpenApi(z) 6 | 7 | // Helper type for capability function parameters 8 | export type CapabilityFuncParams = { 9 | args: z.infer 10 | action?: z.infer 11 | } 12 | 13 | export const agentKind = z.enum(['external', 'eliza', 'openserv']) 14 | 15 | export const taskStatusSchema = z 16 | .enum(['to-do', 'in-progress', 'human-assistance-required', 'error', 'done', 'cancelled']) 17 | .openapi('taskStatusSchema') 18 | 19 | export type TaskStatus = z.infer 20 | 21 | const projectManagerPlanReviewHumanAssistanceQuestionSchema = z.object({ 22 | tasks: z.array( 23 | z.object({ 24 | index: z.number(), 25 | assigneeAgentId: z.number().int(), 26 | assigneeAgentName: z.string(), 27 | taskDescription: z.string(), 28 | taskBody: z.string(), 29 | input: z.string(), 30 | expectedOutput: z.string() 31 | }) 32 | ) 33 | }) 34 | 35 | const baseHumanAssistanceRequestSchema = z.discriminatedUnion('type', [ 36 | z 37 | .object({ 38 | type: z.literal('text'), 39 | question: z.object({ 40 | type: z.literal('text'), 41 | question: z.string().trim().min(1).openapi({ description: 'Your question for the user' }) 42 | }) 43 | }) 44 | .openapi({ 45 | description: 46 | "The type is 'text' and the question is a known object. This is what your agents will typically use." 47 | }), 48 | z.object({ 49 | type: z.literal('project-manager-plan-review'), 50 | question: projectManagerPlanReviewHumanAssistanceQuestionSchema.extend({ 51 | type: z.literal('project-manager-plan-review') 52 | }) 53 | }), 54 | z.object({ 55 | type: z.literal('insufficient-balance'), 56 | question: z.object({ 57 | type: z.literal('insufficient-balance') 58 | }) 59 | }), 60 | z.object({ 61 | type: z.literal('json'), 62 | question: z.any() 63 | }) 64 | ]) 65 | 66 | export const doTaskActionSchema = z 67 | .object({ 68 | type: z.literal('do-task'), 69 | me: z 70 | .intersection( 71 | z.object({ 72 | id: z.number(), 73 | name: z.string(), 74 | kind: agentKind 75 | }), 76 | z 77 | .union([ 78 | z.object({ 79 | isBuiltByAgentBuilder: z.literal(false) 80 | }), 81 | z.object({ 82 | isBuiltByAgentBuilder: z.literal(true), 83 | systemPrompt: z.string() 84 | }) 85 | ]) 86 | .openapi({ 87 | description: 'This information is for internal agents only' 88 | }) 89 | ) 90 | .openapi({ description: 'Your agent instance' }), 91 | task: z.object({ 92 | id: z.number().openapi({ description: 'The ID of the task' }), 93 | description: z.string().openapi({ 94 | description: "Short description of the task. Usually in the format of 'Do [something]'" 95 | }), 96 | body: z.string().nullish().openapi({ 97 | description: 'Additional task information or data. Usually 2-3 sentences if available.' 98 | }), 99 | expectedOutput: z.string().nullish().openapi({ description: 'Preferred output of the task' }), 100 | input: z.string().nullish().openapi({ 101 | description: 102 | "The input information for the task. Typically, it's an output of another task." 103 | }), 104 | dependencies: z 105 | .array( 106 | z.object({ 107 | id: z.number(), 108 | description: z.string(), 109 | output: z.string().nullish(), 110 | status: taskStatusSchema, 111 | attachments: z.array( 112 | z.object({ 113 | id: z.number(), 114 | path: z.string(), 115 | fullUrl: z.string(), 116 | summary: z.string().nullish() 117 | }) 118 | ) 119 | }) 120 | ) 121 | .openapi({ description: 'List of dependant tasks' }), 122 | humanAssistanceRequests: z.array( 123 | z.intersection( 124 | baseHumanAssistanceRequestSchema, 125 | z 126 | .object({ 127 | agentDump: z.unknown().openapi({ 128 | description: 129 | "Agent's internal data. Anything the agent wanted to store in the context of this human assistant request." 130 | }), 131 | humanResponse: z 132 | .string() 133 | .nullish() 134 | .openapi({ description: "Human's response to the question" }), 135 | id: z.number(), 136 | status: z.enum(['pending', 'responded']) 137 | }) 138 | .openapi({ description: 'List of Human Assistance Requests' }) 139 | ) 140 | ) 141 | }), 142 | workspace: z.object({ 143 | id: z.number(), 144 | goal: z.string(), 145 | bucket_folder: z.string(), 146 | agents: z.array( 147 | z.object({ 148 | id: z.number(), 149 | name: z.string(), 150 | capabilities_description: z.string() 151 | }) 152 | ) 153 | }), 154 | integrations: z.array( 155 | z.object({ 156 | id: z.number(), 157 | connection_id: z.string(), 158 | provider_config_key: z.string(), 159 | provider: z.string(), 160 | created: z.string(), 161 | metadata: z.record(z.string(), z.unknown()).nullish(), 162 | scopes: z.array(z.string()).optional(), 163 | openAPI: z.object({ 164 | title: z.string(), 165 | description: z.string() 166 | }) 167 | }) 168 | ), 169 | memories: z.array( 170 | z.object({ 171 | id: z.number(), 172 | memory: z.string(), 173 | createdAt: z.coerce.date() 174 | }) 175 | ) 176 | }) 177 | .openapi('doTaskActionSchema') 178 | 179 | export const respondChatMessageActionSchema = z 180 | .object({ 181 | type: z.literal('respond-chat-message'), 182 | me: z.intersection( 183 | z.object({ 184 | id: z.number(), 185 | name: z.string(), 186 | kind: agentKind 187 | }), 188 | z.discriminatedUnion('isBuiltByAgentBuilder', [ 189 | z.object({ 190 | isBuiltByAgentBuilder: z.literal(false) 191 | }), 192 | z.object({ 193 | isBuiltByAgentBuilder: z.literal(true), 194 | systemPrompt: z.string() 195 | }) 196 | ]) 197 | ), 198 | messages: z.array( 199 | z.object({ 200 | author: z.enum(['agent', 'user']), 201 | createdAt: z.coerce.date(), 202 | id: z.number(), 203 | message: z.string() 204 | }) 205 | ), 206 | workspace: z.object({ 207 | id: z.number(), 208 | goal: z.string(), 209 | bucket_folder: z.string(), 210 | agents: z.array( 211 | z.object({ 212 | id: z.number(), 213 | name: z.string(), 214 | capabilities_description: z.string() 215 | }) 216 | ) 217 | }), 218 | integrations: z.array( 219 | z.object({ 220 | id: z.number(), 221 | connection_id: z.string(), 222 | provider_config_key: z.string(), 223 | provider: z.string(), 224 | created: z.string().optional(), 225 | metadata: z.record(z.string(), z.unknown()).nullish().optional(), 226 | scopes: z.array(z.string()).optional(), 227 | openAPI: z.object({ 228 | title: z.string(), 229 | description: z.string() 230 | }) 231 | }) 232 | ), 233 | memories: z.array( 234 | z.object({ 235 | id: z.number(), 236 | memory: z.string(), 237 | createdAt: z.coerce.date() 238 | }) 239 | ) 240 | }) 241 | .openapi('respondChatMessageActionSchema') 242 | 243 | export const actionSchema = z.discriminatedUnion('type', [ 244 | doTaskActionSchema, 245 | respondChatMessageActionSchema 246 | ]) 247 | 248 | const agentChatMessagesResponseSchema = z.object({ 249 | agent: z.object({ 250 | id: z.number(), 251 | name: z.string() 252 | }), 253 | messages: z.array( 254 | z.object({ 255 | author: z.enum(['agent', 'user']), 256 | createdAt: z.coerce.date(), 257 | id: z.number(), 258 | message: z.string() 259 | }) 260 | ) 261 | }) 262 | 263 | export type AgentChatMessagesResponse = z.infer 264 | 265 | export interface GetFilesParams { 266 | workspaceId: number 267 | } 268 | 269 | export interface GetSecretsParams { 270 | workspaceId: number 271 | } 272 | export interface GetSecretValueParams { 273 | workspaceId: number 274 | secretId: number 275 | } 276 | 277 | export const getFilesParamsSchema = z.object({ 278 | workspaceId: z.number().int().positive() 279 | }) 280 | 281 | export interface UploadFileParams { 282 | workspaceId: number 283 | path: string 284 | taskIds?: number[] | number | null 285 | skipSummarizer?: boolean 286 | file: Buffer | string 287 | } 288 | 289 | export interface DeleteFileParams { 290 | workspaceId: number 291 | fileId: number 292 | } 293 | 294 | export interface MarkTaskAsErroredParams { 295 | workspaceId: number 296 | taskId: number 297 | error: string 298 | } 299 | 300 | export interface CompleteTaskParams { 301 | workspaceId: number 302 | taskId: number 303 | output: string 304 | } 305 | 306 | export interface SendChatMessageParams { 307 | workspaceId: number 308 | agentId: number 309 | message: string 310 | } 311 | 312 | export interface GetTaskDetailParams { 313 | workspaceId: number 314 | taskId: number 315 | } 316 | 317 | export interface GetAgentsParams { 318 | workspaceId: number 319 | } 320 | 321 | export interface GetChatMessagesParams { 322 | workspaceId: number 323 | agentId: number 324 | } 325 | 326 | export interface GetTasksParams { 327 | workspaceId: number 328 | } 329 | 330 | export interface CreateTaskParams { 331 | workspaceId: number 332 | assignee: number 333 | description: string 334 | body: string 335 | input: string 336 | expectedOutput: string 337 | dependencies: number[] 338 | } 339 | 340 | export interface AddLogToTaskParams { 341 | workspaceId: number 342 | taskId: number 343 | severity: 'info' | 'warning' | 'error' 344 | type: 'text' | 'openai-message' 345 | body: string | object 346 | } 347 | 348 | export interface RequestHumanAssistanceParams { 349 | workspaceId: number 350 | taskId: number 351 | type: 'text' | 'project-manager-plan-review' 352 | question: string | object 353 | agentDump?: object 354 | } 355 | 356 | export interface UpdateTaskStatusParams { 357 | workspaceId: number 358 | taskId: number 359 | status: TaskStatus 360 | } 361 | 362 | export interface ProcessParams { 363 | messages: ChatCompletionMessageParam[] 364 | } 365 | 366 | export interface ProxyConfiguration { 367 | endpoint: string 368 | providerConfigKey?: string 369 | connectionId?: string 370 | method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'get' | 'post' | 'patch' | 'put' | 'delete' 371 | headers?: Record 372 | params?: string | Record 373 | data?: unknown 374 | retries?: number 375 | baseUrlOverride?: string 376 | decompress?: boolean 377 | responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' 378 | retryOn?: number[] | null 379 | } 380 | 381 | export interface IntegrationCallRequest { 382 | workspaceId: number 383 | integrationId: string 384 | details: ProxyConfiguration 385 | } 386 | -------------------------------------------------------------------------------- /test/agent.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'node:test' 2 | import { Agent } from '../src' 3 | import { z } from 'zod' 4 | import assert from 'node:assert' 5 | import { BadRequest as BadRequestError } from 'http-errors' 6 | import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' 7 | 8 | const mockApiKey = 'test-key' 9 | 10 | // Create a test class that exposes protected/private members for testing 11 | class TestAgent extends Agent { 12 | // Public accessors for testing 13 | public get testServer() { 14 | // @ts-expect-error Accessing private member for testing 15 | return this.server 16 | } 17 | 18 | public get testPort() { 19 | // @ts-expect-error Accessing private member for testing 20 | return this.port 21 | } 22 | 23 | public get testOpenAiTools() { 24 | // @ts-expect-error Accessing private member for testing 25 | return this.openAiTools 26 | } 27 | 28 | public testSetupRoutes() { 29 | // @ts-expect-error Accessing private member for testing 30 | this.setupRoutes() 31 | } 32 | } 33 | 34 | describe('Agent', () => { 35 | test('should handle tool route validation error', async () => { 36 | let handledError: Error | undefined 37 | let handledContext: Record | undefined 38 | 39 | const agent = new Agent({ 40 | apiKey: mockApiKey, 41 | systemPrompt: 'You are a test agent', 42 | onError: (error, context) => { 43 | handledError = error 44 | handledContext = context 45 | } 46 | }) 47 | 48 | agent.addCapability({ 49 | name: 'testTool', 50 | description: 'A test tool', 51 | schema: z.object({ 52 | input: z.string() 53 | }), 54 | run: async ({ args }) => args.input 55 | }) 56 | 57 | try { 58 | await agent.handleToolRoute({ 59 | params: { toolName: 'testTool' }, 60 | body: { args: { input: 123 } } 61 | }) 62 | assert.fail('Expected error to be thrown') 63 | } catch (error) { 64 | assert.ok(error instanceof z.ZodError) 65 | assert.ok(handledError instanceof z.ZodError) 66 | assert.ok(handledError.issues[0].message.includes('Expected string, received number')) 67 | assert.equal(handledContext?.context, 'handle_tool_route') 68 | } 69 | }) 70 | 71 | test('should handle tool route with missing tool', async () => { 72 | let handledError: Error | undefined 73 | let handledContext: Record | undefined 74 | 75 | const agent = new Agent({ 76 | apiKey: mockApiKey, 77 | systemPrompt: 'You are a test agent', 78 | onError: (error, context) => { 79 | handledError = error 80 | handledContext = context 81 | } 82 | }) 83 | 84 | try { 85 | await agent.handleToolRoute({ 86 | params: { toolName: 'nonexistentTool' }, 87 | body: { args: {} } 88 | }) 89 | assert.fail('Expected error to be thrown') 90 | } catch (error) { 91 | assert.ok(error instanceof BadRequestError) 92 | assert.ok(handledError instanceof BadRequestError) 93 | assert.equal(handledError.message, 'Tool "nonexistentTool" not found') 94 | assert.equal(handledContext?.context, 'handle_tool_route') 95 | } 96 | }) 97 | 98 | test('should handle process request', async () => { 99 | const agent = new Agent({ 100 | apiKey: mockApiKey, 101 | systemPrompt: 'You are a test agent', 102 | openaiApiKey: 'test-key' 103 | }) 104 | 105 | agent.addCapability({ 106 | name: 'testTool', 107 | description: 'A test tool', 108 | schema: z.object({ 109 | input: z.string() 110 | }), 111 | run: async ({ args }) => args.input 112 | }) 113 | 114 | // Mock the OpenAI client 115 | Object.defineProperty(agent, '_openai', { 116 | value: { 117 | chat: { 118 | completions: { 119 | create: async () => ({ 120 | choices: [ 121 | { 122 | message: { 123 | role: 'assistant', 124 | content: 'Hello', 125 | tool_calls: undefined 126 | } 127 | } 128 | ] 129 | }) 130 | } 131 | } 132 | }, 133 | writable: true 134 | }) 135 | 136 | const response = await agent.process({ 137 | messages: [ 138 | { 139 | role: 'user', 140 | content: 'Hello' 141 | } 142 | ] 143 | }) 144 | 145 | assert.ok(response.choices[0].message) 146 | }) 147 | 148 | test('should add system prompt when not present in messages', async () => { 149 | const agent = new Agent({ 150 | apiKey: mockApiKey, 151 | systemPrompt: 'You are a helpful assistant', 152 | openaiApiKey: 'test-key' 153 | }) 154 | 155 | let capturedMessages: any[] = [] 156 | // Mock the OpenAI client to capture messages 157 | Object.defineProperty(agent, '_openai', { 158 | value: { 159 | chat: { 160 | completions: { 161 | create: async ({ messages }: { messages: any[] }) => { 162 | capturedMessages = messages 163 | return { 164 | choices: [ 165 | { 166 | message: { 167 | role: 'assistant', 168 | content: 'Response', 169 | tool_calls: undefined 170 | } 171 | } 172 | ] 173 | } 174 | } 175 | } 176 | } 177 | }, 178 | writable: true 179 | }) 180 | 181 | await agent.process({ 182 | messages: [{ role: 'user', content: 'Hello' }] 183 | }) 184 | 185 | // Verify system prompt was added at the beginning 186 | assert.strictEqual(capturedMessages.length, 2) 187 | assert.strictEqual(capturedMessages[0].role, 'system') 188 | assert.strictEqual(capturedMessages[0].content, 'You are a helpful assistant') 189 | assert.strictEqual(capturedMessages[1].role, 'user') 190 | assert.strictEqual(capturedMessages[1].content, 'Hello') 191 | }) 192 | 193 | test('should not duplicate system prompt when already present', async () => { 194 | const agent = new Agent({ 195 | apiKey: mockApiKey, 196 | systemPrompt: 'You are a helpful assistant', 197 | openaiApiKey: 'test-key' 198 | }) 199 | 200 | let capturedMessages: any[] = [] 201 | // Mock the OpenAI client to capture messages 202 | Object.defineProperty(agent, '_openai', { 203 | value: { 204 | chat: { 205 | completions: { 206 | create: async ({ messages }: { messages: any[] }) => { 207 | capturedMessages = messages 208 | return { 209 | choices: [ 210 | { 211 | message: { 212 | role: 'assistant', 213 | content: 'Response', 214 | tool_calls: undefined 215 | } 216 | } 217 | ] 218 | } 219 | } 220 | } 221 | } 222 | }, 223 | writable: true 224 | }) 225 | 226 | await agent.process({ 227 | messages: [ 228 | { role: 'system', content: 'You are a helpful assistant' }, 229 | { role: 'user', content: 'Hello' } 230 | ] 231 | }) 232 | 233 | // Verify system prompt was not duplicated 234 | assert.strictEqual(capturedMessages.length, 2) 235 | assert.strictEqual(capturedMessages[0].role, 'system') 236 | assert.strictEqual(capturedMessages[0].content, 'You are a helpful assistant') 237 | assert.strictEqual(capturedMessages[1].role, 'user') 238 | assert.strictEqual(capturedMessages[1].content, 'Hello') 239 | 240 | // Ensure there's only one system message with the prompt 241 | const systemMessages = capturedMessages.filter( 242 | m => m.role === 'system' && m.content === 'You are a helpful assistant' 243 | ) 244 | assert.strictEqual(systemMessages.length, 1) 245 | }) 246 | 247 | test('should add system prompt when different system message exists', async () => { 248 | const agent = new Agent({ 249 | apiKey: mockApiKey, 250 | systemPrompt: 'You are a helpful assistant', 251 | openaiApiKey: 'test-key' 252 | }) 253 | 254 | let capturedMessages: any[] = [] 255 | // Mock the OpenAI client to capture messages 256 | Object.defineProperty(agent, '_openai', { 257 | value: { 258 | chat: { 259 | completions: { 260 | create: async ({ messages }: { messages: any[] }) => { 261 | capturedMessages = messages 262 | return { 263 | choices: [ 264 | { 265 | message: { 266 | role: 'assistant', 267 | content: 'Response', 268 | tool_calls: undefined 269 | } 270 | } 271 | ] 272 | } 273 | } 274 | } 275 | } 276 | }, 277 | writable: true 278 | }) 279 | 280 | await agent.process({ 281 | messages: [ 282 | { role: 'system', content: 'Different system message' }, 283 | { role: 'user', content: 'Hello' } 284 | ] 285 | }) 286 | 287 | // Verify system prompt was added because content was different 288 | assert.strictEqual(capturedMessages.length, 3) 289 | assert.strictEqual(capturedMessages[0].role, 'system') 290 | assert.strictEqual(capturedMessages[0].content, 'You are a helpful assistant') 291 | assert.strictEqual(capturedMessages[1].role, 'system') 292 | assert.strictEqual(capturedMessages[1].content, 'Different system message') 293 | assert.strictEqual(capturedMessages[2].role, 'user') 294 | assert.strictEqual(capturedMessages[2].content, 'Hello') 295 | }) 296 | 297 | test('should handle empty messages array', async () => { 298 | const agent = new Agent({ 299 | apiKey: mockApiKey, 300 | systemPrompt: 'You are a helpful assistant', 301 | openaiApiKey: 'test-key' 302 | }) 303 | 304 | let capturedMessages: any[] = [] 305 | // Mock the OpenAI client to capture messages 306 | Object.defineProperty(agent, '_openai', { 307 | value: { 308 | chat: { 309 | completions: { 310 | create: async ({ messages }: { messages: any[] }) => { 311 | capturedMessages = messages 312 | return { 313 | choices: [ 314 | { 315 | message: { 316 | role: 'assistant', 317 | content: 'Response', 318 | tool_calls: undefined 319 | } 320 | } 321 | ] 322 | } 323 | } 324 | } 325 | } 326 | }, 327 | writable: true 328 | }) 329 | 330 | await agent.process({ 331 | messages: [] 332 | }) 333 | 334 | // Verify system prompt was added to empty array 335 | assert.strictEqual(capturedMessages.length, 1) 336 | assert.strictEqual(capturedMessages[0].role, 'system') 337 | assert.strictEqual(capturedMessages[0].content, 'You are a helpful assistant') 338 | }) 339 | 340 | test('should preserve original messages array', async () => { 341 | const agent = new Agent({ 342 | apiKey: mockApiKey, 343 | systemPrompt: 'You are a helpful assistant', 344 | openaiApiKey: 'test-key' 345 | }) 346 | 347 | // Mock the OpenAI client 348 | Object.defineProperty(agent, '_openai', { 349 | value: { 350 | chat: { 351 | completions: { 352 | create: async () => ({ 353 | choices: [ 354 | { 355 | message: { 356 | role: 'assistant', 357 | content: 'Response', 358 | tool_calls: undefined 359 | } 360 | } 361 | ] 362 | }) 363 | } 364 | } 365 | }, 366 | writable: true 367 | }) 368 | 369 | const originalMessages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'Hello' }] 370 | const messagesCopy = [...originalMessages] 371 | 372 | await agent.process({ 373 | messages: originalMessages 374 | }) 375 | 376 | // Verify original array was not modified 377 | assert.deepStrictEqual(originalMessages, messagesCopy) 378 | assert.strictEqual(originalMessages.length, 1) 379 | }) 380 | 381 | test('should handle messages with complex content', async () => { 382 | const agent = new Agent({ 383 | apiKey: mockApiKey, 384 | systemPrompt: 'You are a helpful assistant', 385 | openaiApiKey: 'test-key' 386 | }) 387 | 388 | let capturedMessages: any[] = [] 389 | // Mock the OpenAI client to capture messages 390 | Object.defineProperty(agent, '_openai', { 391 | value: { 392 | chat: { 393 | completions: { 394 | create: async ({ messages }: { messages: any[] }) => { 395 | capturedMessages = messages 396 | return { 397 | choices: [ 398 | { 399 | message: { 400 | role: 'assistant', 401 | content: 'Response', 402 | tool_calls: undefined 403 | } 404 | } 405 | ] 406 | } 407 | } 408 | } 409 | } 410 | }, 411 | writable: true 412 | }) 413 | 414 | await agent.process({ 415 | messages: [ 416 | { role: 'user', content: 'Hello' }, 417 | { role: 'assistant', content: 'Hi there!' }, 418 | { role: 'user', content: 'How are you?' } 419 | ] 420 | }) 421 | 422 | // Verify system prompt was added at the beginning 423 | assert.strictEqual(capturedMessages.length, 4) 424 | assert.strictEqual(capturedMessages[0].role, 'system') 425 | assert.strictEqual(capturedMessages[0].content, 'You are a helpful assistant') 426 | }) 427 | 428 | test('should handle system prompt in middle of conversation', async () => { 429 | const agent = new Agent({ 430 | apiKey: mockApiKey, 431 | systemPrompt: 'You are a helpful assistant', 432 | openaiApiKey: 'test-key' 433 | }) 434 | 435 | let capturedMessages: any[] = [] 436 | // Mock the OpenAI client to capture messages 437 | Object.defineProperty(agent, '_openai', { 438 | value: { 439 | chat: { 440 | completions: { 441 | create: async ({ messages }: { messages: any[] }) => { 442 | capturedMessages = messages 443 | return { 444 | choices: [ 445 | { 446 | message: { 447 | role: 'assistant', 448 | content: 'Response', 449 | tool_calls: undefined 450 | } 451 | } 452 | ] 453 | } 454 | } 455 | } 456 | } 457 | }, 458 | writable: true 459 | }) 460 | 461 | await agent.process({ 462 | messages: [ 463 | { role: 'user', content: 'Hello' }, 464 | { role: 'system', content: 'You are a helpful assistant' }, 465 | { role: 'user', content: 'How are you?' } 466 | ] 467 | }) 468 | 469 | // Verify system prompt was not added because it already exists 470 | assert.strictEqual(capturedMessages.length, 3) 471 | // System prompt should remain in its original position 472 | assert.strictEqual(capturedMessages[1].role, 'system') 473 | assert.strictEqual(capturedMessages[1].content, 'You are a helpful assistant') 474 | }) 475 | }) 476 | 477 | describe('Agent API Methods', () => { 478 | test('should handle file operations', async () => { 479 | const agent = new Agent({ 480 | apiKey: mockApiKey, 481 | systemPrompt: 'You are a test agent' 482 | }) 483 | 484 | // Mock the API client 485 | Object.defineProperty(agent, 'apiClient', { 486 | value: { 487 | get: async () => ({ data: { files: [] } }), 488 | post: async () => ({ data: { fileId: 'test-file-id' } }) 489 | }, 490 | writable: true 491 | }) 492 | 493 | const files = await agent.getFiles({ workspaceId: 1 }) 494 | assert.deepStrictEqual(files, { files: [] }) 495 | 496 | const uploadResult = await agent.uploadFile({ 497 | workspaceId: 1, 498 | path: 'test.txt', 499 | file: 'test content' 500 | }) 501 | assert.deepStrictEqual(uploadResult, { fileId: 'test-file-id' }) 502 | }) 503 | 504 | test('should handle task operations', async () => { 505 | const agent = new Agent({ 506 | apiKey: mockApiKey, 507 | systemPrompt: 'You are a test agent' 508 | }) 509 | 510 | // Mock the API client with all required methods 511 | Object.defineProperty(agent, 'apiClient', { 512 | value: { 513 | post: async () => ({ data: { success: true } }), 514 | get: async () => ({ data: { tasks: [] } }), 515 | put: async () => ({ data: { success: true } }) 516 | }, 517 | writable: true 518 | }) 519 | 520 | const markErrored = await agent.markTaskAsErrored({ 521 | workspaceId: 1, 522 | taskId: 1, 523 | error: 'Test error' 524 | }) 525 | assert.deepStrictEqual(markErrored, { success: true }) 526 | 527 | const complete = await agent.completeTask({ 528 | workspaceId: 1, 529 | taskId: 1, 530 | output: 'Test result' 531 | }) 532 | assert.deepStrictEqual(complete, { success: true }) 533 | 534 | const tasks = await agent.getTasks({ workspaceId: 1 }) 535 | assert.deepStrictEqual(tasks, { tasks: [] }) 536 | }) 537 | 538 | test('should handle chat operations', async () => { 539 | const agent = new Agent({ 540 | apiKey: mockApiKey, 541 | systemPrompt: 'You are a test agent' 542 | }) 543 | 544 | const mockChatMessages = { 545 | agent: { id: 1, name: 'Calculator Agent' }, 546 | messages: [ 547 | { 548 | id: 398, 549 | author: 'user', 550 | message: 'What is the result of 2 + 2?', 551 | createdAt: '2025-04-22T12:10:49.595Z' 552 | }, 553 | { 554 | id: 399, 555 | author: 'agent', 556 | message: 'The result is 4', 557 | createdAt: '2025-04-22T12:12:27.910Z' 558 | } 559 | ] 560 | } 561 | 562 | // Mock the API client 563 | Object.defineProperty(agent, 'apiClient', { 564 | value: { 565 | post: async () => ({ data: { success: true } }), 566 | get: async () => ({ data: mockChatMessages }) 567 | }, 568 | writable: true 569 | }) 570 | 571 | const result = await agent.sendChatMessage({ 572 | workspaceId: 1, 573 | agentId: 1, 574 | message: 'Test message' 575 | }) 576 | assert.deepStrictEqual(result, { success: true }) 577 | 578 | const messages = await agent.getChatMessages({ 579 | workspaceId: 1, 580 | agentId: 1 581 | }) 582 | assert.deepStrictEqual(messages, mockChatMessages) 583 | }) 584 | 585 | test('should handle human assistance operations', async () => { 586 | const agent = new Agent({ 587 | apiKey: mockApiKey, 588 | systemPrompt: 'You are a test agent' 589 | }) 590 | 591 | // Mock the API client 592 | Object.defineProperty(agent, 'apiClient', { 593 | value: { 594 | post: async () => ({ data: { success: true } }) 595 | }, 596 | writable: true 597 | }) 598 | 599 | const result = await agent.requestHumanAssistance({ 600 | workspaceId: 1, 601 | taskId: 1, 602 | type: 'text', 603 | question: 'Need help' 604 | }) 605 | assert.deepStrictEqual(result, { success: true }) 606 | }) 607 | 608 | test('should handle server lifecycle', async () => { 609 | const agent = new TestAgent({ 610 | apiKey: mockApiKey, 611 | systemPrompt: 'You are a test agent', 612 | port: 0 // Use random available port 613 | }) 614 | 615 | await agent.start() 616 | assert.ok(agent.testServer, 'Server should be started') 617 | 618 | // Wait for server to fully stop 619 | await agent.stop() 620 | await new Promise(resolve => setTimeout(resolve, 100)) // Give time for cleanup 621 | assert.ok(!agent.testServer?.listening, 'Server should not be listening') 622 | }) 623 | 624 | test('should handle tool execution with action context', async () => { 625 | const agent = new Agent({ 626 | apiKey: mockApiKey, 627 | systemPrompt: 'You are a test agent' 628 | }) 629 | 630 | const testTool = { 631 | name: 'testTool', 632 | description: 'A test tool', 633 | schema: z.object({ 634 | input: z.string() 635 | }), 636 | run: async ({ args, action }) => { 637 | assert.ok(action, 'Action context should be provided') 638 | return args.input 639 | } 640 | } 641 | 642 | agent.addCapability(testTool) 643 | 644 | const result = await agent.handleToolRoute({ 645 | params: { toolName: 'testTool' }, 646 | body: { 647 | args: { input: 'test' }, 648 | action: { 649 | type: 'do-task', 650 | me: { 651 | id: 1, 652 | name: 'test-agent', 653 | kind: 'external', 654 | isBuiltByAgentBuilder: false 655 | }, 656 | task: { 657 | id: 1, 658 | description: 'Test task', 659 | dependencies: [], 660 | humanAssistanceRequests: [] 661 | }, 662 | workspace: { 663 | id: 1, 664 | goal: 'Test goal', 665 | bucket_folder: 'test', 666 | agents: [] 667 | }, 668 | integrations: [], 669 | memories: [] 670 | } 671 | } 672 | }) 673 | 674 | assert.deepStrictEqual(result, { result: 'test' }) 675 | }) 676 | 677 | test('should handle root route with invalid action', async () => { 678 | let handledError: Error | undefined 679 | let handledContext: Record | undefined 680 | 681 | const agent = new Agent({ 682 | apiKey: mockApiKey, 683 | systemPrompt: 'You are a test agent', 684 | onError: (error, context) => { 685 | handledError = error 686 | handledContext = context 687 | } 688 | }) 689 | 690 | await agent.handleRootRoute({ 691 | body: { 692 | type: 'invalid-action', 693 | me: { 694 | id: 1, 695 | name: 'test-agent', 696 | kind: 'external', 697 | isBuiltByAgentBuilder: false 698 | } 699 | } 700 | }) 701 | 702 | assert.ok(handledError instanceof z.ZodError) 703 | const zodError = handledError as z.ZodError 704 | assert.ok(zodError.issues[0].message.includes('Invalid discriminator value')) 705 | assert.ok(zodError.issues[0].message.includes('do-task')) 706 | assert.ok(zodError.issues[0].message.includes('respond-chat-message')) 707 | assert.equal(handledContext?.context, 'handle_root_route') 708 | }) 709 | }) 710 | 711 | describe('Agent Initialization', () => { 712 | test('should throw error when API key is missing', () => { 713 | assert.throws( 714 | () => { 715 | new Agent({ 716 | systemPrompt: 'You are a test agent' 717 | }) 718 | }, 719 | { 720 | message: 721 | 'OpenServ API key is required. Please provide it in options or set OPENSERV_API_KEY environment variable.' 722 | } 723 | ) 724 | }) 725 | 726 | test('should use default port when not provided', () => { 727 | const agent = new TestAgent({ 728 | apiKey: mockApiKey, 729 | systemPrompt: 'You are a test agent' 730 | }) 731 | assert.strictEqual(agent.testPort, 7378) // Default port 732 | }) 733 | }) 734 | 735 | describe('Agent File Operations', () => { 736 | test('should handle file upload with all options', async () => { 737 | const agent = new Agent({ 738 | apiKey: mockApiKey, 739 | systemPrompt: 'You are a test agent' 740 | }) 741 | 742 | // Mock the API client 743 | Object.defineProperty(agent, 'apiClient', { 744 | value: { 745 | post: async (url: string, data: FormData) => { 746 | // Verify FormData contents 747 | assert.ok(data.has('path')) 748 | assert.ok(data.has('taskIds')) 749 | assert.ok(data.has('skipSummarizer')) 750 | assert.ok(data.has('file')) 751 | return { data: { fileId: 'test-file-id' } } 752 | } 753 | }, 754 | writable: true 755 | }) 756 | 757 | const uploadResult = await agent.uploadFile({ 758 | workspaceId: 1, 759 | path: 'test.txt', 760 | file: Buffer.from('test content'), 761 | taskIds: [1, 2], 762 | skipSummarizer: true 763 | }) 764 | assert.deepStrictEqual(uploadResult, { fileId: 'test-file-id' }) 765 | }) 766 | 767 | test('should handle file upload with string content', async () => { 768 | const agent = new Agent({ 769 | apiKey: mockApiKey, 770 | systemPrompt: 'You are a test agent' 771 | }) 772 | 773 | // Mock the API client 774 | Object.defineProperty(agent, 'apiClient', { 775 | value: { 776 | post: async (url: string, data: FormData) => { 777 | assert.ok(data.has('file')) 778 | return { data: { fileId: 'test-file-id' } } 779 | } 780 | }, 781 | writable: true 782 | }) 783 | 784 | const uploadResult = await agent.uploadFile({ 785 | workspaceId: 1, 786 | path: 'test.txt', 787 | file: 'test content' 788 | }) 789 | assert.deepStrictEqual(uploadResult, { fileId: 'test-file-id' }) 790 | }) 791 | 792 | test('should handle file deletion', async () => { 793 | const agent = new Agent({ 794 | apiKey: mockApiKey, 795 | systemPrompt: 'You are a test agent' 796 | }) 797 | 798 | // Mock the API client 799 | Object.defineProperty(agent, 'apiClient', { 800 | value: { 801 | delete: async (url: string) => { 802 | assert.deepStrictEqual(url, '/workspaces/1/files/123') 803 | return { data: { message: 'File deleted successfully' } } 804 | } 805 | }, 806 | writable: true 807 | }) 808 | 809 | const deleteResult = await agent.deleteFile({ 810 | workspaceId: 1, 811 | fileId: 123 812 | }) 813 | assert.deepStrictEqual(deleteResult, { message: 'File deleted successfully' }) 814 | }) 815 | 816 | test('should get secrets collection', async () => { 817 | const agent = new Agent({ 818 | apiKey: mockApiKey, 819 | systemPrompt: 'You are a test agent' 820 | }) 821 | 822 | const mockSecretCollection = [ 823 | { 824 | id: 1, 825 | name: 'My secret' 826 | } 827 | ] 828 | 829 | // Mock the API client 830 | Object.defineProperty(agent, 'apiClient', { 831 | value: { 832 | get: async () => ({ data: mockSecretCollection }) 833 | }, 834 | writable: true 835 | }) 836 | 837 | const secretCollection = await agent.getSecrets({ 838 | workspaceId: 1 839 | }) 840 | assert.deepStrictEqual(secretCollection, mockSecretCollection) 841 | }) 842 | 843 | test('should get revealed secret value', async () => { 844 | const agent = new Agent({ 845 | apiKey: mockApiKey, 846 | systemPrompt: 'You are a test agent' 847 | }) 848 | 849 | const mockSecretRevealedalue = 'MyRevealedSecretValue' 850 | 851 | // Mock the API client 852 | Object.defineProperty(agent, 'apiClient', { 853 | value: { 854 | get: async () => ({ data: mockSecretRevealedalue }) 855 | }, 856 | writable: true 857 | }) 858 | 859 | const secretValue = await agent.getSecretValue({ 860 | workspaceId: 1, 861 | secretId: 1 862 | }) 863 | assert.deepStrictEqual(secretValue, mockSecretRevealedalue) 864 | }) 865 | }) 866 | 867 | describe('Agent Task Operations', () => { 868 | test('should get task details', async () => { 869 | const agent = new Agent({ 870 | apiKey: mockApiKey, 871 | systemPrompt: 'You are a test agent' 872 | }) 873 | 874 | const mockTaskDetail = { 875 | id: 1, 876 | description: 'Test task', 877 | status: 'in-progress' 878 | } 879 | 880 | // Mock the API client 881 | Object.defineProperty(agent, 'apiClient', { 882 | value: { 883 | get: async () => ({ data: mockTaskDetail }) 884 | }, 885 | writable: true 886 | }) 887 | 888 | const taskDetail = await agent.getTaskDetail({ 889 | workspaceId: 1, 890 | taskId: 1 891 | }) 892 | assert.deepStrictEqual(taskDetail, mockTaskDetail) 893 | }) 894 | 895 | test('should get agents in workspace', async () => { 896 | const agent = new Agent({ 897 | apiKey: mockApiKey, 898 | systemPrompt: 'You are a test agent' 899 | }) 900 | 901 | const mockAgents = [ 902 | { 903 | id: 1, 904 | name: 'Test Agent', 905 | capabilities_description: 'Test capabilities' 906 | } 907 | ] 908 | 909 | // Mock the API client 910 | Object.defineProperty(agent, 'apiClient', { 911 | value: { 912 | get: async () => ({ data: mockAgents }) 913 | }, 914 | writable: true 915 | }) 916 | 917 | const agents = await agent.getAgents({ 918 | workspaceId: 1 919 | }) 920 | assert.deepStrictEqual(agents, mockAgents) 921 | }) 922 | }) 923 | 924 | describe('Agent Task Management', () => { 925 | test('should create task with all options', async () => { 926 | const agent = new Agent({ 927 | apiKey: mockApiKey, 928 | systemPrompt: 'You are a test agent' 929 | }) 930 | 931 | const mockTask = { 932 | id: 1, 933 | description: 'Test task', 934 | status: 'to-do' 935 | } 936 | 937 | // Mock the API client 938 | Object.defineProperty(agent, 'apiClient', { 939 | value: { 940 | post: async ( 941 | url: string, 942 | data: { 943 | description: string 944 | body: string 945 | input: string 946 | expectedOutput: string 947 | dependencies: number[] 948 | } 949 | ) => { 950 | assert.strictEqual(data.description, 'Test task') 951 | assert.strictEqual(data.body, 'Task body') 952 | assert.strictEqual(data.input, 'Task input') 953 | assert.strictEqual(data.expectedOutput, 'Expected output') 954 | assert.deepStrictEqual(data.dependencies, [1, 2]) 955 | return { data: mockTask } 956 | } 957 | }, 958 | writable: true 959 | }) 960 | 961 | const task = await agent.createTask({ 962 | workspaceId: 1, 963 | assignee: 1, 964 | description: 'Test task', 965 | body: 'Task body', 966 | input: 'Task input', 967 | expectedOutput: 'Expected output', 968 | dependencies: [1, 2] 969 | }) 970 | assert.deepStrictEqual(task, mockTask) 971 | }) 972 | 973 | test('should add log to task', async () => { 974 | const agent = new Agent({ 975 | apiKey: mockApiKey, 976 | systemPrompt: 'You are a test agent' 977 | }) 978 | 979 | type LogData = { 980 | severity: string 981 | type: string 982 | body: string | Record 983 | } 984 | 985 | type ApiData = Record 986 | 987 | // Mock the API client 988 | Object.defineProperty(agent, 'apiClient', { 989 | value: { 990 | post: async (_url: string, data: ApiData) => { 991 | const typedData = data as LogData 992 | assert.strictEqual(typedData.severity, 'info') 993 | assert.strictEqual(typedData.type, 'text') 994 | assert.strictEqual(typedData.body, 'Test log') 995 | return { data: { id: 1, ...typedData } } 996 | } 997 | }, 998 | writable: true 999 | }) 1000 | 1001 | const log = await agent.addLogToTask({ 1002 | workspaceId: 1, 1003 | taskId: 1, 1004 | severity: 'info', 1005 | type: 'text', 1006 | body: 'Test log' 1007 | }) 1008 | assert.deepStrictEqual(log, { id: 1, severity: 'info', type: 'text', body: 'Test log' }) 1009 | }) 1010 | 1011 | test('should update task status', async () => { 1012 | const agent = new Agent({ 1013 | apiKey: mockApiKey, 1014 | systemPrompt: 'You are a test agent' 1015 | }) 1016 | 1017 | interface StatusData { 1018 | status: string 1019 | } 1020 | 1021 | // Mock the API client 1022 | Object.defineProperty(agent, 'apiClient', { 1023 | value: { 1024 | put: async (_url: string, data: StatusData) => { 1025 | assert.strictEqual(data.status, 'in-progress') 1026 | return { data: { success: true } } 1027 | } 1028 | }, 1029 | writable: true 1030 | }) 1031 | 1032 | const response = await agent.updateTaskStatus({ 1033 | workspaceId: 1, 1034 | taskId: 1, 1035 | status: 'in-progress' 1036 | }) 1037 | assert.deepStrictEqual(response, { success: true }) 1038 | }) 1039 | }) 1040 | 1041 | describe('Agent Process Methods', () => { 1042 | test('should handle empty OpenAI response', async () => { 1043 | const agent = new Agent({ 1044 | apiKey: mockApiKey, 1045 | systemPrompt: 'You are a test agent', 1046 | openaiApiKey: 'test-key' 1047 | }) 1048 | 1049 | interface EmptyResponseMock { 1050 | chat: { 1051 | completions: { 1052 | create: () => Promise<{ 1053 | choices: never[] 1054 | }> 1055 | } 1056 | } 1057 | } 1058 | 1059 | // Mock the OpenAI client with empty response 1060 | Object.defineProperty(agent, '_openai', { 1061 | value: { 1062 | chat: { 1063 | completions: { 1064 | create: async () => ({ 1065 | choices: [] 1066 | }) 1067 | } 1068 | } 1069 | } as EmptyResponseMock, 1070 | writable: true 1071 | }) 1072 | 1073 | try { 1074 | await agent.process({ 1075 | messages: [{ role: 'user', content: 'Hello' }] 1076 | }) 1077 | throw new Error('Should have thrown error for empty response') 1078 | } catch (error) { 1079 | assert.ok(error instanceof Error) 1080 | assert.strictEqual(error.message, 'No response from OpenAI') 1081 | } 1082 | }) 1083 | 1084 | test('should handle OpenAI response with tool calls', async () => { 1085 | const agent = new Agent({ 1086 | apiKey: mockApiKey, 1087 | systemPrompt: 'You are a test agent', 1088 | openaiApiKey: 'test-key' 1089 | }) 1090 | 1091 | // Add a test tool 1092 | agent.addCapability({ 1093 | name: 'testTool', 1094 | description: 'A test tool', 1095 | schema: z.object({ 1096 | input: z.string() 1097 | }), 1098 | run: async ({ args }) => args.input 1099 | }) 1100 | 1101 | let callCount = 0 1102 | // Mock the OpenAI client with tool calls followed by completion 1103 | Object.defineProperty(agent, '_openai', { 1104 | value: { 1105 | chat: { 1106 | completions: { 1107 | create: async () => { 1108 | callCount++ 1109 | if (callCount === 1) { 1110 | return { 1111 | choices: [ 1112 | { 1113 | message: { 1114 | role: 'assistant', 1115 | content: null, 1116 | tool_calls: [ 1117 | { 1118 | id: 'call_1', 1119 | type: 'function', 1120 | function: { 1121 | name: 'testTool', 1122 | arguments: JSON.stringify({ input: 'test' }) 1123 | } 1124 | } 1125 | ] 1126 | } 1127 | } 1128 | ] 1129 | } 1130 | } 1131 | return { 1132 | choices: [ 1133 | { 1134 | message: { 1135 | role: 'assistant', 1136 | content: 'Task completed', 1137 | tool_calls: undefined 1138 | } 1139 | } 1140 | ] 1141 | } 1142 | } 1143 | } 1144 | } 1145 | }, 1146 | writable: true 1147 | }) 1148 | 1149 | const response = await agent.process({ 1150 | messages: [{ role: 'user', content: 'Hello' }] 1151 | }) 1152 | 1153 | assert.ok(response.choices[0].message) 1154 | assert.strictEqual(response.choices[0].message.content, 'Task completed') 1155 | }) 1156 | }) 1157 | 1158 | describe('Agent Action Handling', () => { 1159 | test('should handle do-task action', async () => { 1160 | const agent = new Agent({ 1161 | apiKey: mockApiKey, 1162 | systemPrompt: 'You are a test agent', 1163 | openaiApiKey: 'test-key' 1164 | }) 1165 | 1166 | // Mock both OpenAI and runtime clients 1167 | Object.defineProperty(agent, '_openai', { 1168 | value: { 1169 | chat: { 1170 | completions: { 1171 | create: async () => ({ 1172 | choices: [ 1173 | { 1174 | message: { 1175 | role: 'assistant', 1176 | content: 'Task handled', 1177 | tool_calls: undefined 1178 | } 1179 | } 1180 | ] 1181 | }) 1182 | } 1183 | } 1184 | }, 1185 | writable: true 1186 | }) 1187 | 1188 | Object.defineProperty(agent, 'runtimeClient', { 1189 | value: { 1190 | post: async () => ({ data: { success: true } }) 1191 | }, 1192 | writable: true 1193 | }) 1194 | 1195 | const action = { 1196 | type: 'do-task' as const, 1197 | me: { 1198 | id: 1, 1199 | name: 'test-agent', 1200 | kind: 'external' as const, 1201 | isBuiltByAgentBuilder: false 1202 | }, 1203 | task: { 1204 | id: 1, 1205 | description: 'Test task', 1206 | dependencies: [], 1207 | humanAssistanceRequests: [] 1208 | }, 1209 | workspace: { 1210 | id: 1, 1211 | goal: 'Test goal', 1212 | bucket_folder: 'test', 1213 | agents: [] 1214 | }, 1215 | integrations: [], 1216 | memories: [] 1217 | } 1218 | 1219 | await agent.handleRootRoute({ body: action }) 1220 | }) 1221 | 1222 | test('should handle respond-chat-message action', async () => { 1223 | const agent = new Agent({ 1224 | apiKey: mockApiKey, 1225 | systemPrompt: 'You are a test agent', 1226 | openaiApiKey: 'test-key' 1227 | }) 1228 | 1229 | interface OpenAIClientMock { 1230 | chat: { 1231 | completions: { 1232 | create: () => Promise<{ 1233 | choices: Array<{ 1234 | message: { 1235 | role: string 1236 | content: string 1237 | tool_calls: undefined 1238 | } 1239 | }> 1240 | }> 1241 | } 1242 | } 1243 | } 1244 | 1245 | // Mock both OpenAI and runtime clients 1246 | Object.defineProperty(agent, '_openai', { 1247 | value: { 1248 | chat: { 1249 | completions: { 1250 | create: async () => ({ 1251 | choices: [ 1252 | { 1253 | message: { 1254 | role: 'assistant', 1255 | content: 'Chat response', 1256 | tool_calls: undefined 1257 | } 1258 | } 1259 | ] 1260 | }) 1261 | } 1262 | } 1263 | } as OpenAIClientMock, 1264 | writable: true 1265 | }) 1266 | 1267 | Object.defineProperty(agent, 'runtimeClient', { 1268 | value: { 1269 | post: async () => ({ data: { success: true } }) 1270 | }, 1271 | writable: true 1272 | }) 1273 | 1274 | const action = { 1275 | type: 'respond-chat-message' as const, 1276 | me: { 1277 | id: 1, 1278 | name: 'test-agent', 1279 | kind: 'external' as const, 1280 | isBuiltByAgentBuilder: false 1281 | }, 1282 | messages: [ 1283 | { 1284 | author: 'user' as const, 1285 | createdAt: new Date(), 1286 | id: 1, 1287 | message: 'Hello' 1288 | } 1289 | ], 1290 | workspace: { 1291 | id: 1, 1292 | goal: 'Test goal', 1293 | bucket_folder: 'test', 1294 | agents: [] 1295 | }, 1296 | integrations: [], 1297 | memories: [] 1298 | } 1299 | 1300 | await agent.handleRootRoute({ body: action }) 1301 | }) 1302 | }) 1303 | 1304 | describe('Agent Route Setup', () => { 1305 | test('should setup routes correctly', async () => { 1306 | const agent = new TestAgent({ 1307 | apiKey: mockApiKey, 1308 | systemPrompt: 'You are a test agent' 1309 | }) 1310 | 1311 | // Mock the router and app 1312 | type RouteHandler = ( 1313 | req: { body?: Record; params?: Record }, 1314 | res: { status: (code: number) => { json: (data: unknown) => void } }, 1315 | next?: () => void 1316 | ) => void | Promise 1317 | 1318 | const routes: { path: string; method: string; handler: RouteHandler }[] = [] 1319 | const mockRouter = { 1320 | get: (path: string, handler: RouteHandler) => { 1321 | routes.push({ path, method: 'GET', handler }) 1322 | }, 1323 | post: (path: string, handler: RouteHandler) => { 1324 | routes.push({ path, method: 'POST', handler }) 1325 | } 1326 | } 1327 | 1328 | Object.defineProperty(agent, 'router', { 1329 | value: mockRouter, 1330 | writable: true 1331 | }) 1332 | 1333 | const addRoute = (path: string, method: string, handler: RouteHandler) => { 1334 | routes.push({ path, method, handler }) 1335 | } 1336 | 1337 | Object.defineProperty(agent, 'app', { 1338 | value: { 1339 | use: (pathOrHandler: string | RouteHandler, maybeHandler?: RouteHandler) => { 1340 | if (typeof pathOrHandler === 'string' && maybeHandler) { 1341 | addRoute(pathOrHandler, 'USE', maybeHandler) 1342 | return 1343 | } 1344 | if (typeof pathOrHandler === 'function') { 1345 | addRoute('/', 'USE', pathOrHandler) 1346 | } 1347 | } 1348 | }, 1349 | writable: true 1350 | }) 1351 | 1352 | // Call setupRoutes again to test route registration 1353 | agent.testSetupRoutes() 1354 | 1355 | // Verify routes were set up 1356 | assert.ok(routes.some(r => r.path === '/health' && r.method === 'GET')) 1357 | assert.ok(routes.some(r => r.path === '/tools/:toolName' && r.method === 'POST')) 1358 | assert.ok(routes.some(r => r.path === '/' && r.method === 'POST')) 1359 | }) 1360 | 1361 | test('should convert tools to OpenAI format', () => { 1362 | const agent = new TestAgent({ 1363 | apiKey: mockApiKey, 1364 | systemPrompt: 'You are a test agent' 1365 | }) 1366 | 1367 | const testTool = { 1368 | name: 'testTool', 1369 | description: 'A test tool', 1370 | schema: z.object({ 1371 | input: z.string() 1372 | }), 1373 | run: async ({ args }) => args.input 1374 | } 1375 | 1376 | agent.addCapability(testTool) 1377 | 1378 | const openAiTools = agent.testOpenAiTools 1379 | assert.strictEqual(openAiTools.length, 1) 1380 | assert.strictEqual(openAiTools[0].type, 'function') 1381 | assert.strictEqual(openAiTools[0].function.name, 'testTool') 1382 | assert.strictEqual(openAiTools[0].function.description, 'A test tool') 1383 | }) 1384 | }) 1385 | 1386 | describe('Agent Integration Operations', () => { 1387 | test('should call integration endpoint successfully', async () => { 1388 | const agent = new Agent({ 1389 | apiKey: mockApiKey, 1390 | systemPrompt: 'You are a test agent' 1391 | }) 1392 | 1393 | const mockResponse = { 1394 | data: { 1395 | output: { 1396 | data: { 1397 | text: 'Hello from integration!' 1398 | } 1399 | } 1400 | } 1401 | } 1402 | 1403 | // Mock the API client 1404 | Object.defineProperty(agent, 'apiClient', { 1405 | value: { 1406 | post: async (url: string, data: unknown) => { 1407 | assert.strictEqual(url, '/workspaces/1/integration/test-integration/proxy') 1408 | assert.deepStrictEqual(data, { 1409 | endpoint: '/test', 1410 | method: 'POST', 1411 | data: { message: 'test' } 1412 | }) 1413 | return mockResponse 1414 | } 1415 | }, 1416 | writable: true 1417 | }) 1418 | 1419 | const response = await agent.callIntegration({ 1420 | workspaceId: 1, 1421 | integrationId: 'test-integration', 1422 | details: { 1423 | endpoint: '/test', 1424 | method: 'POST', 1425 | data: { message: 'test' } 1426 | } 1427 | }) 1428 | 1429 | assert.deepStrictEqual(response, mockResponse.data) 1430 | }) 1431 | 1432 | test('should handle integration call without data payload', async () => { 1433 | const agent = new Agent({ 1434 | apiKey: mockApiKey, 1435 | systemPrompt: 'You are a test agent' 1436 | }) 1437 | 1438 | const mockResponse = { 1439 | data: { 1440 | output: { 1441 | data: { 1442 | status: 'success' 1443 | } 1444 | } 1445 | } 1446 | } 1447 | 1448 | // Mock the API client 1449 | Object.defineProperty(agent, 'apiClient', { 1450 | value: { 1451 | post: async (url: string, data: unknown) => { 1452 | assert.strictEqual(url, '/workspaces/1/integration/test-integration/proxy') 1453 | assert.deepStrictEqual(data, { 1454 | endpoint: '/test', 1455 | method: 'GET' 1456 | }) 1457 | return mockResponse 1458 | } 1459 | }, 1460 | writable: true 1461 | }) 1462 | 1463 | const response = await agent.callIntegration({ 1464 | workspaceId: 1, 1465 | integrationId: 'test-integration', 1466 | details: { 1467 | endpoint: '/test', 1468 | method: 'GET' 1469 | } 1470 | }) 1471 | 1472 | assert.deepStrictEqual(response, mockResponse.data) 1473 | }) 1474 | 1475 | test('should handle integration call errors', async () => { 1476 | const agent = new Agent({ 1477 | apiKey: mockApiKey, 1478 | systemPrompt: 'You are a test agent' 1479 | }) 1480 | 1481 | const mockError = new Error('Integration error') 1482 | 1483 | // Mock the API client 1484 | Object.defineProperty(agent, 'apiClient', { 1485 | value: { 1486 | post: async () => { 1487 | throw mockError 1488 | } 1489 | }, 1490 | writable: true 1491 | }) 1492 | 1493 | try { 1494 | await agent.callIntegration({ 1495 | workspaceId: 1, 1496 | integrationId: 'test-integration', 1497 | details: { 1498 | endpoint: '/test', 1499 | method: 'GET' 1500 | } 1501 | }) 1502 | assert.fail('Expected error to be thrown') 1503 | } catch (error) { 1504 | assert.strictEqual(error, mockError) 1505 | } 1506 | }) 1507 | }) 1508 | -------------------------------------------------------------------------------- /test/api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { Agent } from '../src/agent' 4 | import type OpenAI from 'openai' 5 | import type { doTaskActionSchema, respondChatMessageActionSchema } from '../src/types' 6 | import type { z } from 'zod' 7 | import { BadRequest as BadRequestError } from 'http-errors' 8 | 9 | // Create a test class that exposes protected methods for testing 10 | class TestAgent extends Agent { 11 | public async testDoTask(action: z.infer) { 12 | return this.doTask(action) 13 | } 14 | 15 | public async testRespondToChat(action: z.infer) { 16 | return this.respondToChat(action) 17 | } 18 | 19 | public set testOpenAI(client: OpenAI) { 20 | this._openai = client 21 | } 22 | } 23 | 24 | describe('Agent API Methods', () => { 25 | const mockApiKey = 'test-openserv-key' 26 | 27 | test('should have all required API methods', () => { 28 | const agent = new Agent({ 29 | apiKey: mockApiKey, 30 | systemPrompt: 'You are a test agent' 31 | }) 32 | 33 | const requiredMethods = [ 34 | 'uploadFile', 35 | 'updateTaskStatus', 36 | 'completeTask', 37 | 'markTaskAsErrored', 38 | 'addLogToTask', 39 | 'requestHumanAssistance', 40 | 'sendChatMessage', 41 | 'createTask', 42 | 'getTaskDetail', 43 | 'getAgents', 44 | 'getTasks', 45 | 'getFiles', 46 | 'process', 47 | 'start', 48 | 'addCapability', 49 | 'addCapabilities' 50 | ] 51 | 52 | for (const method of requiredMethods) { 53 | assert.ok(typeof agent[method] === 'function', `${method} should be a function`) 54 | } 55 | }) 56 | 57 | test('should make process method available when openaiApiKey is provided', () => { 58 | const agent = new Agent({ 59 | apiKey: mockApiKey, 60 | systemPrompt: 'You are a test agent', 61 | openaiApiKey: 'test-openai-key' 62 | }) 63 | 64 | assert.ok(typeof agent.process === 'function') 65 | }) 66 | 67 | test('should throw error when process is called without OpenAI API key', async () => { 68 | // Save original env var 69 | const originalApiKey = process.env.OPENAI_API_KEY 70 | // Clear env var for test 71 | // Using delete here despite the linter warning because it's the only way to properly remove an environment variable 72 | // The performance impact is not a concern in tests 73 | 74 | // biome-ignore lint/performance/noDelete: This is a test, fgs. 75 | delete process.env.OPENAI_API_KEY 76 | 77 | const agent = new Agent({ 78 | apiKey: mockApiKey, 79 | systemPrompt: 'You are a test agent' 80 | }) 81 | 82 | try { 83 | await assert.rejects( 84 | () => agent.process({ messages: [{ role: 'user', content: 'test message' }] }), 85 | { 86 | message: 87 | 'OpenAI API key is required for process(). Please provide it in options or set OPENAI_API_KEY environment variable.' 88 | } 89 | ) 90 | } finally { 91 | // Restore original env var 92 | if (originalApiKey !== undefined) { 93 | process.env.OPENAI_API_KEY = originalApiKey 94 | } 95 | } 96 | }) 97 | 98 | test('should have start method available', () => { 99 | const agent = new Agent({ 100 | apiKey: mockApiKey, 101 | systemPrompt: 'You are a test agent' 102 | }) 103 | 104 | assert.ok(typeof agent.start === 'function') 105 | }) 106 | 107 | test('should use custom error handler when provided', async () => { 108 | let handledError: Error | undefined 109 | let handledContext: Record | undefined 110 | 111 | const agent = new Agent({ 112 | apiKey: mockApiKey, 113 | systemPrompt: 'You are a test agent', 114 | onError: (error, context) => { 115 | handledError = error 116 | handledContext = context 117 | } 118 | }) 119 | 120 | try { 121 | await agent.handleToolRoute({ 122 | params: { toolName: 'nonexistent' }, 123 | body: {} 124 | }) 125 | assert.fail('Expected error to be thrown') 126 | } catch (error) { 127 | assert.ok(error instanceof BadRequestError) 128 | assert.ok(handledError instanceof Error) 129 | assert.equal(handledContext?.context, 'handle_tool_route') 130 | } 131 | }) 132 | 133 | test('should handle errors in process method', async () => { 134 | let handledError: Error | undefined 135 | let handledContext: Record | undefined 136 | 137 | const agent = new TestAgent({ 138 | apiKey: mockApiKey, 139 | systemPrompt: 'You are a test agent', 140 | openaiApiKey: 'test-key', 141 | onError: (error, context) => { 142 | handledError = error 143 | handledContext = context 144 | } 145 | }) 146 | 147 | // Mock OpenAI to throw an error 148 | agent.testOpenAI = { 149 | chat: { 150 | completions: { 151 | create: async () => { 152 | throw new Error('OpenAI error') 153 | } 154 | } 155 | } 156 | } as unknown as OpenAI 157 | 158 | try { 159 | await agent.process({ messages: [{ role: 'user', content: 'test' }] }) 160 | } catch (error) { 161 | // Expected error 162 | } 163 | 164 | assert.ok(handledError instanceof Error) 165 | assert.equal(handledError?.message, 'OpenAI error') 166 | assert.equal(handledContext?.context, 'process') 167 | }) 168 | 169 | test('should handle errors in doTask method', async () => { 170 | let handledError: Error | undefined 171 | let handledContext: Record | undefined 172 | 173 | const agent = new TestAgent({ 174 | apiKey: mockApiKey, 175 | systemPrompt: 'You are a test agent', 176 | onError: (error, context) => { 177 | handledError = error 178 | handledContext = context 179 | } 180 | }) 181 | 182 | const testAction = { 183 | type: 'do-task' as const, 184 | workspace: { 185 | id: 1, 186 | goal: 'Test workspace', 187 | bucket_folder: 'test', 188 | agents: [ 189 | { 190 | name: 'Test Agent', 191 | id: 1, 192 | capabilities_description: 'Test capabilities' 193 | } 194 | ] 195 | }, 196 | me: { 197 | id: 1, 198 | name: 'Test Agent', 199 | kind: 'external' as const, 200 | isBuiltByAgentBuilder: false as const 201 | } satisfies { name: string; id: number; kind: 'external'; isBuiltByAgentBuilder: false }, 202 | task: { 203 | id: 1, 204 | description: 'test task', 205 | dependencies: [ 206 | { 207 | id: 2, 208 | status: 'done' as const, 209 | description: 'dependency task', 210 | attachments: [], 211 | output: 'test output' 212 | } 213 | ], 214 | humanAssistanceRequests: [], 215 | attachments: [] 216 | }, 217 | integrations: [], 218 | memories: [] 219 | } 220 | 221 | await agent.testDoTask(testAction) 222 | 223 | assert.ok(handledError instanceof Error) 224 | assert.equal(handledContext?.context, 'do_task') 225 | assert.deepEqual(handledContext?.action, testAction) 226 | }) 227 | 228 | test('should handle errors in respondToChat method', async () => { 229 | let handledError: Error | undefined 230 | let handledContext: Record | undefined 231 | 232 | const agent = new TestAgent({ 233 | apiKey: mockApiKey, 234 | systemPrompt: 'You are a test agent', 235 | onError: (error, context) => { 236 | handledError = error 237 | handledContext = context 238 | } 239 | }) 240 | 241 | const testAction = { 242 | type: 'respond-chat-message' as const, 243 | workspace: { 244 | id: 1, 245 | goal: 'Test workspace', 246 | bucket_folder: 'test', 247 | agents: [ 248 | { 249 | name: 'Test Agent', 250 | id: 1, 251 | capabilities_description: 'Test capabilities' 252 | } 253 | ] 254 | }, 255 | me: { 256 | id: 1, 257 | name: 'Test Agent', 258 | kind: 'external' as const, 259 | isBuiltByAgentBuilder: false as const 260 | } satisfies { name: string; id: number; kind: 'external'; isBuiltByAgentBuilder: false }, 261 | integrations: [], 262 | memories: [], 263 | messages: [] 264 | } 265 | 266 | await agent.testRespondToChat(testAction) 267 | 268 | assert.ok(handledError instanceof Error) 269 | assert.equal(handledContext?.context, 'respond_to_chat') 270 | assert.deepEqual(handledContext?.action, testAction) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /test/capabilities.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { z } from 'zod' 4 | import { Agent, Capability } from '../src' 5 | 6 | describe('Agent Capabilities', () => { 7 | const mockApiKey = 'test-openserv-key' 8 | 9 | test('should execute a capability function and return the expected output', async () => { 10 | const agent = new Agent({ 11 | apiKey: mockApiKey, 12 | systemPrompt: 'You are a test agent' 13 | }) 14 | 15 | agent.addCapability({ 16 | name: 'testCapability', 17 | description: 'A test capability', 18 | schema: z.object({ 19 | input: z.string() 20 | }), 21 | run: async ({ args }) => args.input 22 | }) 23 | 24 | const result = await agent.handleToolRoute({ 25 | params: { toolName: 'testCapability' }, 26 | body: { args: { input: 'test' } } 27 | }) 28 | 29 | assert.deepStrictEqual(result, { result: 'test' }) 30 | }) 31 | 32 | test('should validate capability schema', () => { 33 | const capability = new Capability( 34 | 'testCapability', 35 | 'A test capability', 36 | z.object({ 37 | input: z.number() 38 | }), 39 | async ({ args }) => args.input.toString() 40 | ) 41 | 42 | assert.throws( 43 | () => capability.schema.parse({ input: 'not a number' }), 44 | err => err instanceof z.ZodError 45 | ) 46 | }) 47 | 48 | test('should handle multiple capabilities', async () => { 49 | const agent = new Agent({ 50 | apiKey: mockApiKey, 51 | systemPrompt: 'You are a test agent' 52 | }) 53 | 54 | const capabilities = [ 55 | { 56 | name: 'tool1', 57 | description: 'Tool 1', 58 | schema: z.object({ input: z.string() }), 59 | run: async ({ args }) => args.input 60 | }, 61 | { 62 | name: 'tool2', 63 | description: 'Tool 2', 64 | schema: z.object({ input: z.string() }), 65 | run: async ({ args }) => args.input 66 | } 67 | ] as const 68 | 69 | agent.addCapabilities(capabilities) 70 | 71 | // Test that both tools are available by trying to execute them 72 | await Promise.all([ 73 | agent 74 | .handleToolRoute({ 75 | params: { toolName: 'tool1' }, 76 | body: { args: { input: 'test1' } } 77 | }) 78 | .then(result => assert.deepStrictEqual(result, { result: 'test1' })), 79 | agent 80 | .handleToolRoute({ 81 | params: { toolName: 'tool2' }, 82 | body: { args: { input: 'test2' } } 83 | }) 84 | .then(result => assert.deepStrictEqual(result, { result: 'test2' })) 85 | ]) 86 | }) 87 | 88 | test('should throw error when adding duplicate capability', () => { 89 | const agent = new Agent({ 90 | apiKey: mockApiKey, 91 | systemPrompt: 'You are a test agent' 92 | }) 93 | 94 | agent.addCapability({ 95 | name: 'test', 96 | description: 'Tool 1', 97 | schema: z.object({ input: z.string() }), 98 | run: async ({ args }) => args.input 99 | }) 100 | 101 | assert.throws( 102 | () => 103 | agent.addCapability({ 104 | name: 'test', 105 | description: 'Tool 1 duplicate', 106 | schema: z.object({ input: z.string() }), 107 | run: async ({ args }) => args.input 108 | }), 109 | { 110 | message: 'Tool with name "test" already exists' 111 | } 112 | ) 113 | }) 114 | 115 | test('should throw error when adding capabilities with duplicate names', () => { 116 | const agent = new Agent({ 117 | apiKey: mockApiKey, 118 | systemPrompt: 'You are a test agent' 119 | }) 120 | 121 | const capabilities = [ 122 | { 123 | name: 'tool1', 124 | description: 'Tool 1', 125 | schema: z.object({ input: z.string() }), 126 | run: async ({ args }) => args.input 127 | }, 128 | { 129 | name: 'tool1', 130 | description: 'Tool 1 duplicate', 131 | schema: z.object({ input: z.string() }), 132 | run: async ({ args }) => args.input 133 | } 134 | ] as const 135 | 136 | assert.throws(() => agent.addCapabilities(capabilities), { 137 | message: 'Tool with name "tool1" already exists' 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /test/logger.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, test } from 'node:test' 3 | import { createLogger, logger } from '../src/logger' 4 | 5 | describe('Logger', () => { 6 | const originalLogLevel = process.env.LOG_LEVEL 7 | 8 | test('should create logger with default level', () => { 9 | delete process.env.LOG_LEVEL 10 | const logger = createLogger() 11 | assert.strictEqual(logger.level, 'info') 12 | }) 13 | 14 | test('should create logger with custom level from env', () => { 15 | process.env.LOG_LEVEL = 'debug' 16 | const logger = createLogger() 17 | assert.strictEqual(logger.level, 'debug') 18 | }) 19 | 20 | test('should create logger with null log level', () => { 21 | delete process.env.LOG_LEVEL 22 | const logger = createLogger() 23 | assert.strictEqual(logger.level, 'info') 24 | }) 25 | 26 | test('should export default logger instance', () => { 27 | assert.ok(logger) 28 | assert.strictEqual(typeof logger.info, 'function') 29 | assert.strictEqual(typeof logger.error, 'function') 30 | assert.strictEqual(typeof logger.warn, 'function') 31 | assert.strictEqual(typeof logger.debug, 'function') 32 | }) 33 | 34 | test.after(() => { 35 | if (originalLogLevel) { 36 | process.env.LOG_LEVEL = originalLogLevel 37 | } else { 38 | delete process.env.LOG_LEVEL 39 | } 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import type { ChatCompletion } from 'openai/resources/chat/completions' 2 | import type { APIPromise } from 'openai/core' 3 | 4 | export function createMockOpenAIResponse(response: ChatCompletion): { 5 | create: () => APIPromise 6 | } { 7 | return { 8 | create: () => Promise.resolve(response) as APIPromise 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, test } from 'node:test' 3 | import { 4 | actionSchema, 5 | doTaskActionSchema, 6 | respondChatMessageActionSchema, 7 | UploadFileParams, 8 | GetFilesParams, 9 | MarkTaskAsErroredParams, 10 | CompleteTaskParams, 11 | SendChatMessageParams, 12 | GetTaskDetailParams, 13 | agentKind, 14 | taskStatusSchema, 15 | GetAgentsParams, 16 | GetTasksParams, 17 | CreateTaskParams, 18 | AddLogToTaskParams, 19 | RequestHumanAssistanceParams, 20 | UpdateTaskStatusParams, 21 | ProcessParams, 22 | getFilesParamsSchema, 23 | GetChatMessagesParams 24 | } from '../src/types' 25 | 26 | describe('Action Schemas', () => { 27 | test('should validate do-task action', () => { 28 | const action = { 29 | type: 'do-task', 30 | me: { 31 | id: 1, 32 | name: 'test-agent', 33 | kind: 'external', 34 | isBuiltByAgentBuilder: false 35 | }, 36 | task: { 37 | id: 1, 38 | description: 'test task', 39 | body: 'test body', 40 | expectedOutput: 'test output', 41 | input: 'test input', 42 | dependencies: [], 43 | humanAssistanceRequests: [] 44 | }, 45 | workspace: { 46 | id: 1, 47 | goal: 'test goal', 48 | bucket_folder: 'test-folder', 49 | agents: [] 50 | }, 51 | integrations: [], 52 | memories: [] 53 | } 54 | 55 | const result = doTaskActionSchema.parse(action) 56 | assert.deepStrictEqual(result, action) 57 | }) 58 | 59 | test('should validate respond-chat-message action', () => { 60 | const action = { 61 | type: 'respond-chat-message', 62 | me: { 63 | id: 1, 64 | name: 'test-agent', 65 | kind: 'external', 66 | isBuiltByAgentBuilder: false 67 | }, 68 | messages: [ 69 | { 70 | author: 'user', 71 | createdAt: new Date(), 72 | id: 1, 73 | message: 'test message' 74 | } 75 | ], 76 | workspace: { 77 | id: 1, 78 | goal: 'test goal', 79 | bucket_folder: 'test-folder', 80 | agents: [] 81 | }, 82 | integrations: [], 83 | memories: [] 84 | } 85 | 86 | const result = respondChatMessageActionSchema.parse(action) 87 | assert.deepStrictEqual(result, action) 88 | }) 89 | 90 | test('should validate action with discriminated union', () => { 91 | const doTaskAction = { 92 | type: 'do-task', 93 | me: { 94 | id: 1, 95 | name: 'test-agent', 96 | kind: 'external', 97 | isBuiltByAgentBuilder: false 98 | }, 99 | task: { 100 | id: 1, 101 | description: 'test task', 102 | body: 'test body', 103 | expectedOutput: 'test output', 104 | input: 'test input', 105 | dependencies: [], 106 | humanAssistanceRequests: [] 107 | }, 108 | workspace: { 109 | id: 1, 110 | goal: 'test goal', 111 | bucket_folder: 'test-folder', 112 | agents: [] 113 | }, 114 | integrations: [], 115 | memories: [] 116 | } 117 | 118 | const respondChatAction = { 119 | type: 'respond-chat-message', 120 | me: { 121 | id: 1, 122 | name: 'test-agent', 123 | kind: 'external', 124 | isBuiltByAgentBuilder: false 125 | }, 126 | messages: [ 127 | { 128 | author: 'user', 129 | createdAt: new Date(), 130 | id: 1, 131 | message: 'test message' 132 | } 133 | ], 134 | workspace: { 135 | id: 1, 136 | goal: 'test goal', 137 | bucket_folder: 'test-folder', 138 | agents: [] 139 | }, 140 | integrations: [], 141 | memories: [] 142 | } 143 | 144 | const doTaskResult = actionSchema.parse(doTaskAction) 145 | assert.deepStrictEqual(doTaskResult, doTaskAction) 146 | 147 | const respondChatResult = actionSchema.parse(respondChatAction) 148 | assert.deepStrictEqual(respondChatResult, respondChatAction) 149 | }) 150 | 151 | test('should reject invalid action type', () => { 152 | const action = { 153 | type: 'invalid-type', 154 | me: { 155 | id: 1, 156 | name: 'test-agent', 157 | kind: 'external', 158 | isBuiltByAgentBuilder: false 159 | } 160 | } 161 | 162 | assert.throws(() => actionSchema.parse(action)) 163 | }) 164 | 165 | test('should reject invalid do-task action', () => { 166 | const action = { 167 | type: 'do-task', 168 | me: { 169 | id: 1, 170 | name: 'test-agent', 171 | kind: 'invalid-kind', 172 | isBuiltByAgentBuilder: false 173 | } 174 | } 175 | 176 | assert.throws(() => doTaskActionSchema.parse(action)) 177 | }) 178 | 179 | test('should reject invalid respond-chat-message action', () => { 180 | const action = { 181 | type: 'respond-chat-message', 182 | me: { 183 | id: 1, 184 | name: 'test-agent', 185 | kind: 'external', 186 | isBuiltByAgentBuilder: false 187 | }, 188 | messages: [ 189 | { 190 | author: 'invalid-author', 191 | createdAt: new Date(), 192 | id: 1, 193 | message: 'test message' 194 | } 195 | ] 196 | } 197 | 198 | assert.throws(() => respondChatMessageActionSchema.parse(action)) 199 | }) 200 | 201 | test('should validate UploadFileParams with all variations', () => { 202 | // Test with array of taskIds 203 | const params1: UploadFileParams = { 204 | workspaceId: 1, 205 | path: 'test.txt', 206 | file: 'test content', 207 | taskIds: [1, 2, 3], 208 | skipSummarizer: true 209 | } 210 | assert.ok(params1) 211 | 212 | // Test with single taskId 213 | const params2: UploadFileParams = { 214 | workspaceId: 1, 215 | path: 'test.txt', 216 | file: 'test content', 217 | taskIds: 1 218 | } 219 | assert.ok(params2) 220 | 221 | // Test with null taskIds 222 | const params3: UploadFileParams = { 223 | workspaceId: 1, 224 | path: 'test.txt', 225 | file: 'test content', 226 | taskIds: null 227 | } 228 | assert.ok(params3) 229 | 230 | // Test with skipSummarizer false 231 | const params4: UploadFileParams = { 232 | workspaceId: 1, 233 | path: 'test.txt', 234 | file: 'test content', 235 | skipSummarizer: false 236 | } 237 | assert.ok(params4) 238 | 239 | // Test with Buffer file 240 | const params5: UploadFileParams = { 241 | workspaceId: 1, 242 | path: 'test.txt', 243 | file: Buffer.from('test content') 244 | } 245 | assert.ok(params5) 246 | 247 | // Test with minimum required fields 248 | const params6: UploadFileParams = { 249 | workspaceId: 1, 250 | path: 'test.txt', 251 | file: 'test content' 252 | } 253 | assert.ok(params6) 254 | }) 255 | 256 | test('should validate GetFilesParams', () => { 257 | // Test valid case 258 | const params: GetFilesParams = { 259 | workspaceId: 1 260 | } 261 | const result = getFilesParamsSchema.parse(params) 262 | assert.deepStrictEqual(result, params) 263 | 264 | // Test invalid workspaceId types 265 | assert.throws(() => { 266 | getFilesParamsSchema.parse({ 267 | workspaceId: '1' 268 | }) 269 | }, /Expected number, received string/) 270 | 271 | assert.throws(() => { 272 | getFilesParamsSchema.parse({ 273 | workspaceId: null 274 | }) 275 | }, /Expected number, received null/) 276 | 277 | assert.throws(() => { 278 | getFilesParamsSchema.parse({ 279 | workspaceId: undefined 280 | }) 281 | }, /Required/) 282 | 283 | // Test with missing workspaceId 284 | assert.throws(() => { 285 | getFilesParamsSchema.parse({}) 286 | }, /Required/) 287 | 288 | // Test with negative workspaceId 289 | assert.throws(() => { 290 | getFilesParamsSchema.parse({ 291 | workspaceId: -1 292 | }) 293 | }, /Number must be greater than 0/) 294 | 295 | // Test with zero workspaceId 296 | assert.throws(() => { 297 | getFilesParamsSchema.parse({ 298 | workspaceId: 0 299 | }) 300 | }, /Number must be greater than 0/) 301 | 302 | // Test with decimal workspaceId 303 | assert.throws(() => { 304 | getFilesParamsSchema.parse({ 305 | workspaceId: 1.5 306 | }) 307 | }, /Expected integer, received float/) 308 | }) 309 | 310 | test('should validate MarkTaskAsErroredParams', () => { 311 | const params: MarkTaskAsErroredParams = { 312 | workspaceId: 1, 313 | taskId: 2, 314 | error: 'Test error message' 315 | } 316 | assert.ok(params) 317 | assert.strictEqual(typeof params.workspaceId, 'number') 318 | assert.strictEqual(typeof params.taskId, 'number') 319 | assert.strictEqual(typeof params.error, 'string') 320 | }) 321 | 322 | test('should validate CompleteTaskParams', () => { 323 | const params: CompleteTaskParams = { 324 | workspaceId: 1, 325 | taskId: 2, 326 | output: 'Test task output' 327 | } 328 | assert.ok(params) 329 | assert.strictEqual(typeof params.workspaceId, 'number') 330 | assert.strictEqual(typeof params.taskId, 'number') 331 | assert.strictEqual(typeof params.output, 'string') 332 | }) 333 | 334 | test('should validate SendChatMessageParams', () => { 335 | const params: SendChatMessageParams = { 336 | workspaceId: 1, 337 | agentId: 2, 338 | message: 'Test chat message' 339 | } 340 | assert.ok(params) 341 | assert.strictEqual(typeof params.workspaceId, 'number') 342 | assert.strictEqual(typeof params.agentId, 'number') 343 | assert.strictEqual(typeof params.message, 'string') 344 | }) 345 | 346 | test('should validate GetChatMessagesParams', () => { 347 | const params: GetChatMessagesParams = { 348 | workspaceId: 1, 349 | agentId: 2 350 | } 351 | assert.ok(params) 352 | assert.strictEqual(typeof params.workspaceId, 'number') 353 | assert.strictEqual(typeof params.agentId, 'number') 354 | }) 355 | 356 | test('should validate GetTaskDetailParams', () => { 357 | const params: GetTaskDetailParams = { 358 | workspaceId: 1, 359 | taskId: 2 360 | } 361 | assert.ok(params) 362 | assert.strictEqual(typeof params.workspaceId, 'number') 363 | assert.strictEqual(typeof params.taskId, 'number') 364 | }) 365 | 366 | test('should validate agent kind', () => { 367 | assert.strictEqual(agentKind.parse('external'), 'external') 368 | assert.strictEqual(agentKind.parse('eliza'), 'eliza') 369 | assert.strictEqual(agentKind.parse('openserv'), 'openserv') 370 | assert.throws(() => agentKind.parse('invalid')) 371 | }) 372 | 373 | test('should validate task status', () => { 374 | assert.strictEqual(taskStatusSchema.parse('to-do'), 'to-do') 375 | assert.strictEqual(taskStatusSchema.parse('in-progress'), 'in-progress') 376 | assert.strictEqual( 377 | taskStatusSchema.parse('human-assistance-required'), 378 | 'human-assistance-required' 379 | ) 380 | assert.strictEqual(taskStatusSchema.parse('error'), 'error') 381 | assert.strictEqual(taskStatusSchema.parse('done'), 'done') 382 | assert.strictEqual(taskStatusSchema.parse('cancelled'), 'cancelled') 383 | assert.throws(() => taskStatusSchema.parse('invalid')) 384 | }) 385 | 386 | test('should validate do-task action with agent builder', () => { 387 | const action = { 388 | type: 'do-task', 389 | me: { 390 | id: 1, 391 | name: 'test-agent', 392 | kind: 'external', 393 | isBuiltByAgentBuilder: true, 394 | systemPrompt: 'You are a test agent' 395 | }, 396 | task: { 397 | id: 1, 398 | description: 'test task', 399 | body: 'test body', 400 | expectedOutput: 'test output', 401 | input: 'test input', 402 | dependencies: [], 403 | humanAssistanceRequests: [] 404 | }, 405 | workspace: { 406 | id: 1, 407 | goal: 'test goal', 408 | bucket_folder: 'test-folder', 409 | agents: [] 410 | }, 411 | integrations: [], 412 | memories: [] 413 | } 414 | 415 | const result = doTaskActionSchema.parse(action) 416 | assert.deepStrictEqual(result, action) 417 | }) 418 | 419 | test('should validate GetAgentsParams', () => { 420 | const params: GetAgentsParams = { 421 | workspaceId: 1 422 | } 423 | assert.ok(params) 424 | assert.strictEqual(typeof params.workspaceId, 'number') 425 | }) 426 | 427 | test('should validate GetTasksParams', () => { 428 | const params: GetTasksParams = { 429 | workspaceId: 1 430 | } 431 | assert.ok(params) 432 | assert.strictEqual(typeof params.workspaceId, 'number') 433 | }) 434 | 435 | test('should validate CreateTaskParams', () => { 436 | const params: CreateTaskParams = { 437 | workspaceId: 1, 438 | assignee: 2, 439 | description: 'Test task', 440 | body: 'Test body', 441 | input: 'Test input', 442 | expectedOutput: 'Test output', 443 | dependencies: [3, 4] 444 | } 445 | assert.ok(params) 446 | assert.strictEqual(typeof params.workspaceId, 'number') 447 | assert.strictEqual(typeof params.assignee, 'number') 448 | assert.strictEqual(typeof params.description, 'string') 449 | assert.strictEqual(typeof params.body, 'string') 450 | assert.strictEqual(typeof params.input, 'string') 451 | assert.strictEqual(typeof params.expectedOutput, 'string') 452 | assert.ok(Array.isArray(params.dependencies)) 453 | }) 454 | 455 | test('should validate AddLogToTaskParams', () => { 456 | const textParams: AddLogToTaskParams = { 457 | workspaceId: 1, 458 | taskId: 2, 459 | severity: 'info', 460 | type: 'text', 461 | body: 'Test log message' 462 | } 463 | assert.ok(textParams) 464 | 465 | const openaiParams: AddLogToTaskParams = { 466 | workspaceId: 1, 467 | taskId: 2, 468 | severity: 'warning', 469 | type: 'openai-message', 470 | body: { role: 'assistant', content: 'Test message' } 471 | } 472 | assert.ok(openaiParams) 473 | }) 474 | 475 | test('should validate RequestHumanAssistanceParams', () => { 476 | const textParams: RequestHumanAssistanceParams = { 477 | workspaceId: 1, 478 | taskId: 2, 479 | type: 'text', 480 | question: { 481 | type: 'text', 482 | question: 'Test question' 483 | } 484 | } 485 | assert.ok(textParams) 486 | 487 | const reviewParams: RequestHumanAssistanceParams = { 488 | workspaceId: 1, 489 | taskId: 2, 490 | type: 'project-manager-plan-review', 491 | question: { type: 'project-manager-plan-review', tasks: [] }, 492 | agentDump: { data: 'Test data' } 493 | } 494 | assert.ok(reviewParams) 495 | }) 496 | 497 | test('should validate UpdateTaskStatusParams', () => { 498 | const params: UpdateTaskStatusParams = { 499 | workspaceId: 1, 500 | taskId: 2, 501 | status: 'in-progress' 502 | } 503 | assert.ok(params) 504 | assert.strictEqual(typeof params.workspaceId, 'number') 505 | assert.strictEqual(typeof params.taskId, 'number') 506 | assert.strictEqual(params.status, 'in-progress') 507 | }) 508 | 509 | test('should validate ProcessParams', () => { 510 | const params: ProcessParams = { 511 | messages: [ 512 | { role: 'user', content: 'Test message' }, 513 | { role: 'assistant', content: 'Test response' } 514 | ] 515 | } 516 | assert.ok(params) 517 | assert.ok(Array.isArray(params.messages)) 518 | assert.strictEqual(params.messages[0].role, 'user') 519 | assert.strictEqual(params.messages[1].role, 'assistant') 520 | }) 521 | 522 | test('should validate task dependencies', () => { 523 | const action = { 524 | type: 'do-task', 525 | me: { 526 | id: 1, 527 | name: 'test-agent', 528 | kind: 'external', 529 | isBuiltByAgentBuilder: false 530 | }, 531 | task: { 532 | id: 1, 533 | description: 'test task', 534 | body: 'test body', 535 | expectedOutput: 'test output', 536 | input: 'test input', 537 | dependencies: [ 538 | { 539 | id: 2, 540 | description: 'dependency task', 541 | output: 'dependency output', 542 | status: 'done', 543 | attachments: [ 544 | { 545 | id: 3, 546 | path: 'test.txt', 547 | fullUrl: 'http://example.com/test.txt', 548 | summary: 'test summary' 549 | } 550 | ] 551 | } 552 | ], 553 | humanAssistanceRequests: [] 554 | }, 555 | workspace: { 556 | id: 1, 557 | goal: 'test goal', 558 | bucket_folder: 'test-folder', 559 | agents: [] 560 | }, 561 | integrations: [], 562 | memories: [] 563 | } 564 | 565 | const result = doTaskActionSchema.parse(action) 566 | assert.deepStrictEqual(result, action) 567 | 568 | // Test with nullish fields 569 | const actionWithNullish = { 570 | ...action, 571 | task: { 572 | ...action.task, 573 | dependencies: [ 574 | { 575 | id: 2, 576 | description: 'dependency task', 577 | output: null, 578 | status: 'done', 579 | attachments: [ 580 | { 581 | id: 3, 582 | path: 'test.txt', 583 | fullUrl: 'http://example.com/test.txt', 584 | summary: null 585 | } 586 | ] 587 | } 588 | ] 589 | } 590 | } 591 | 592 | const resultWithNullish = doTaskActionSchema.parse(actionWithNullish) 593 | assert.deepStrictEqual(resultWithNullish, actionWithNullish) 594 | }) 595 | 596 | test('should validate human assistance requests', () => { 597 | const action = { 598 | type: 'do-task', 599 | me: { 600 | id: 1, 601 | name: 'test-agent', 602 | kind: 'external', 603 | isBuiltByAgentBuilder: false 604 | }, 605 | task: { 606 | id: 1, 607 | description: 'test task', 608 | body: 'test body', 609 | expectedOutput: 'test output', 610 | input: 'test input', 611 | dependencies: [], 612 | humanAssistanceRequests: [ 613 | { 614 | id: 2, 615 | type: 'text', 616 | question: { 617 | type: 'text', 618 | question: 'test question' 619 | }, 620 | status: 'pending', 621 | agentDump: { data: 'test data' }, 622 | humanResponse: null 623 | }, 624 | { 625 | id: 3, 626 | type: 'project-manager-plan-review', 627 | question: { 628 | type: 'project-manager-plan-review', 629 | tasks: [] 630 | }, 631 | status: 'responded', 632 | agentDump: { plan: 'test plan' }, 633 | humanResponse: 'approved' 634 | } 635 | ] 636 | }, 637 | workspace: { 638 | id: 1, 639 | goal: 'test goal', 640 | bucket_folder: 'test-folder', 641 | agents: [] 642 | }, 643 | integrations: [], 644 | memories: [] 645 | } 646 | 647 | const result = doTaskActionSchema.parse(action) 648 | assert.deepStrictEqual(result, action) 649 | }) 650 | 651 | test('should validate integrations', () => { 652 | const action = { 653 | type: 'do-task', 654 | me: { 655 | id: 1, 656 | name: 'test-agent', 657 | kind: 'external', 658 | isBuiltByAgentBuilder: false 659 | }, 660 | task: { 661 | id: 1, 662 | description: 'test task', 663 | body: 'test body', 664 | expectedOutput: 'test output', 665 | input: 'test input', 666 | dependencies: [], 667 | humanAssistanceRequests: [] 668 | }, 669 | workspace: { 670 | id: 1, 671 | goal: 'test goal', 672 | bucket_folder: 'test-folder', 673 | agents: [ 674 | { 675 | id: 2, 676 | name: 'test agent', 677 | capabilities_description: 'test capabilities' 678 | } 679 | ] 680 | }, 681 | integrations: [ 682 | { 683 | id: 3, 684 | connection_id: 'test-connection', 685 | provider_config_key: 'test-provider', 686 | provider: 'test', 687 | created: '2024-01-01', 688 | metadata: { key: 'value' }, 689 | scopes: ['read', 'write'], 690 | openAPI: { 691 | title: 'Test API', 692 | description: 'Test API description' 693 | } 694 | }, 695 | { 696 | id: 4, 697 | connection_id: 'test-connection-2', 698 | provider_config_key: 'test-provider-2', 699 | provider: 'test-2', 700 | created: '2024-01-02', 701 | metadata: null, 702 | openAPI: { 703 | title: 'Test API 2', 704 | description: 'Test API description 2' 705 | } 706 | } 707 | ], 708 | memories: [ 709 | { 710 | id: 5, 711 | memory: 'test memory', 712 | createdAt: new Date('2024-01-01') 713 | } 714 | ] 715 | } 716 | 717 | const result = doTaskActionSchema.parse(action) 718 | assert.deepStrictEqual(result, action) 719 | }) 720 | 721 | test('should validate respond-chat-message action with all fields', () => { 722 | const action = { 723 | type: 'respond-chat-message', 724 | me: { 725 | id: 1, 726 | name: 'test-agent', 727 | kind: 'external', 728 | isBuiltByAgentBuilder: true, 729 | systemPrompt: 'You are a test agent' 730 | }, 731 | messages: [ 732 | { 733 | author: 'user', 734 | createdAt: new Date(), 735 | id: 1, 736 | message: 'test message' 737 | }, 738 | { 739 | author: 'agent', 740 | createdAt: new Date(), 741 | id: 2, 742 | message: 'test response' 743 | } 744 | ], 745 | workspace: { 746 | id: 1, 747 | goal: 'test goal', 748 | bucket_folder: 'test-folder', 749 | agents: [ 750 | { 751 | id: 2, 752 | name: 'test agent', 753 | capabilities_description: 'test capabilities' 754 | } 755 | ] 756 | }, 757 | integrations: [ 758 | { 759 | id: 3, 760 | connection_id: 'test-connection', 761 | provider_config_key: 'test-provider', 762 | provider: 'test', 763 | created: '2024-01-01', 764 | metadata: { key: 'value' }, 765 | scopes: ['read', 'write'], 766 | openAPI: { 767 | title: 'Test API', 768 | description: 'Test API description' 769 | } 770 | } 771 | ], 772 | memories: [ 773 | { 774 | id: 4, 775 | memory: 'test memory', 776 | createdAt: new Date('2024-01-01') 777 | } 778 | ] 779 | } 780 | 781 | const result = respondChatMessageActionSchema.parse(action) 782 | assert.deepStrictEqual(result, action) 783 | }) 784 | }) 785 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "noPropertyAccessFromIndexSignature": false, 5 | "exactOptionalPropertyTypes": false, 6 | "esModuleInterop": true, 7 | "target": "es2022", 8 | "allowJs": true, 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "resolveJsonModule": true, 12 | "moduleDetection": "force", 13 | "isolatedModules": true, 14 | "noEmit": false, 15 | "outDir": "./dist", 16 | "rootDir": "./src", 17 | "declaration": true, 18 | "declarationMap": true 19 | }, 20 | "include": [ 21 | "src" 22 | ], 23 | "exclude": [ 24 | "node_modules" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------