From 6014437479375969e27b819540d207af11d5bf0a Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:17:06 +0530 Subject: [PATCH 01/38] first commit --- apps/cli/.gitignore | 2 + apps/cli/package-lock.json | 1465 +++++++++++++++++ apps/cli/package.json | 27 + apps/cli/src/app.ts | 185 +++ apps/cli/src/application/config/config.ts | 16 + apps/cli/src/application/entities/agent.ts | 7 + apps/cli/src/application/entities/mcp.ts | 8 + apps/cli/src/application/entities/message.ts | 58 + .../src/application/entities/stream-event.ts | 56 + apps/cli/src/application/entities/workflow.ts | 21 + .../cli/src/application/functions/get_date.ts | 16 + apps/cli/src/application/lib/mcp.ts | 31 + apps/cli/src/application/lib/random-id.ts | 7 + .../src/application/lib/stream-renderer.ts | 151 ++ apps/cli/src/application/nodes/agent.ts | 130 ++ apps/cli/src/application/nodes/node.ts | 10 + .../cli/src/application/registry/functions.ts | 6 + apps/cli/src/application/registry/tools.ts | 0 apps/cli/todo.md | 15 + apps/cli/tsconfig.json | 20 + 20 files changed, 2231 insertions(+) create mode 100644 apps/cli/.gitignore create mode 100644 apps/cli/package-lock.json create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/app.ts create mode 100644 apps/cli/src/application/config/config.ts create mode 100644 apps/cli/src/application/entities/agent.ts create mode 100644 apps/cli/src/application/entities/mcp.ts create mode 100644 apps/cli/src/application/entities/message.ts create mode 100644 apps/cli/src/application/entities/stream-event.ts create mode 100644 apps/cli/src/application/entities/workflow.ts create mode 100644 apps/cli/src/application/functions/get_date.ts create mode 100644 apps/cli/src/application/lib/mcp.ts create mode 100644 apps/cli/src/application/lib/random-id.ts create mode 100644 apps/cli/src/application/lib/stream-renderer.ts create mode 100644 apps/cli/src/application/nodes/agent.ts create mode 100644 apps/cli/src/application/nodes/node.ts create mode 100644 apps/cli/src/application/registry/functions.ts create mode 100644 apps/cli/src/application/registry/tools.ts create mode 100644 apps/cli/todo.md create mode 100644 apps/cli/tsconfig.json diff --git a/apps/cli/.gitignore b/apps/cli/.gitignore new file mode 100644 index 00000000..04c01ba7 --- /dev/null +++ b/apps/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/apps/cli/package-lock.json b/apps/cli/package-lock.json new file mode 100644 index 00000000..23f6872e --- /dev/null +++ b/apps/cli/package-lock.json @@ -0,0 +1,1465 @@ +{ + "name": "cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cli", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@ai-sdk/google": "^2.0.25", + "@ai-sdk/openai": "^2.0.53", + "@modelcontextprotocol/sdk": "^1.20.2", + "ai": "^5.0.78", + "nanoid": "^5.1.6", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", + "integrity": "sha512-vPVIbnP35ZnayS937XLo85vynR85fpBQWHCdUweq7apzqFOTU2YkUd4V3msebEHbQ2Zro60ZShDDy9SMiyWTqA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.12", + "@vercel/oidc": "3.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.25.tgz", + "integrity": "sha512-tH2rA3428jnY6COoPfKB/BoQMs57sv9t+PEdyIB9ePtlV9dnVUbfKcdKoEcAaVffNZ6pzk8otrQYnu67pyn8TQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.14" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.14.tgz", + "integrity": "sha512-CYRU6L7IlR7KslSBVxvlqlybQvXJln/PI57O8swhOaDIURZbjRP2AY3igKgUsrmWqqnFFUHP+AwTN8xqJAknnA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.53.tgz", + "integrity": "sha512-GIkR3+Fyif516ftXv+YPSPstnAHhcZxNoR2s8uSHhQ1yBT7I7aQYTVwpjAuYoT3GR+TeP50q7onj2/nDRbT2FQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.12" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.12.tgz", + "integrity": "sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", + "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz", + "integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ai": { + "version": "5.0.78", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.78.tgz", + "integrity": "sha512-ec77fmQwJGLduswMrW4AAUGSOiu8dZaIwMmWHHGKsrMUFFS6ugfkTyx0srtuKYHNRRLRC2dT7cPirnUl98VnxA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "2.0.1", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.12", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000..165932a0 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "cli", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "rm -rf dist && tsc" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@types/node": "^24.9.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "@ai-sdk/google": "^2.0.25", + "@ai-sdk/openai": "^2.0.53", + "@modelcontextprotocol/sdk": "^1.20.2", + "ai": "^5.0.78", + "nanoid": "^5.1.6", + "zod": "^4.1.12" + } +} diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts new file mode 100644 index 00000000..03f46280 --- /dev/null +++ b/apps/cli/src/app.ts @@ -0,0 +1,185 @@ +import fs from "fs"; +import path from "path"; +import { WorkDir, McpServers } from "./application/config/config.js"; +import { Workflow } from "./application/entities/workflow.js"; +import { FunctionsRegistry } from "./application/registry/functions.js"; +import { AgentNode } from "./application/nodes/agent.js"; +import { MessageList, AssistantContentPart } from "./application/entities/message.js"; +import { z } from "zod"; +import { getMcpClient } from "./application/lib/mcp.js"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { google } from "@ai-sdk/google"; +import { StreamRenderer } from "./application/lib/stream-renderer.js"; +import { StreamEvent } from "./application/entities/stream-event.js"; +import { AssistantMessage, Message } from "./application/entities/message.js"; +import { randomId } from "./application/lib/random-id.js"; + +class RunLogger { + private logFile: string; + private fileHandle: fs.WriteStream; + + ensureRunsDir(workflowId: string) { + const runsDir = path.join(WorkDir, "runs", workflowId); + if (!fs.existsSync(runsDir)) { + fs.mkdirSync(runsDir, { recursive: true }); + } + } + + constructor(workflowId: string, runId: string) { + this.ensureRunsDir(workflowId); + this.logFile = path.join(WorkDir, "runs", `${workflowId}`, `${runId}.jsonl`); + this.fileHandle = fs.createWriteStream(this.logFile, { + flags: "a", + encoding: "utf8", + }); + } + + log(message: z.infer) { + this.fileHandle.write(JSON.stringify(message) + "\n"); + } + + close() { + this.fileHandle.close(); + } +} + +class StreamStepMessageBuilder { + private parts: z.infer[] = []; + private textBuffer: string = ""; + private reasoningBuffer: string = ""; + + flushBuffers() { + if (this.reasoningBuffer) { + this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); + this.reasoningBuffer = ""; + } + if (this.textBuffer) { + this.parts.push({ type: "text", text: this.textBuffer }); + this.textBuffer = ""; + } + } + + ingest(event: z.infer) { + switch (event.type) { + case "reasoning-start": + case "reasoning-end": + case "text-start": + case "text-end": + this.flushBuffers(); + break; + case "reasoning-delta": + this.reasoningBuffer += event.delta; + break; + case "text-delta": + this.textBuffer += event.delta; + break; + case "tool-call": + this.parts.push({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + arguments: event.input, + }); + break; + } + } + + get(): z.infer { + this.flushBuffers(); + return { + role: "assistant", + content: this.parts, + }; + } +} + +function loadWorkflow(id: string) { + const workflowPath = path.join(WorkDir, "workflows", `${id}.json`); + const workflow = fs.readFileSync(workflowPath, "utf8"); + return Workflow.parse(JSON.parse(workflow)); +} + +function loadFunction(id: string) { + const func = FunctionsRegistry[id]; + if (!func) { + throw new Error(`Function ${id} not found`); + } + return func; +} + +async function callMcpTool(serverName: string, toolName: string, args: Record) { + const server = McpServers.find(server => server.name === serverName); + if (!server) { + throw new Error(`MCP server ${serverName} not found`); + } + const client = await getMcpClient(server.url, server.name); + const response = await client.callTool({ name: toolName, arguments: args }); + return response; +} + +async function executeWorkflow(id: string) { + const workflow = loadWorkflow(id); + // console.log("got", JSON.stringify(workflow)); + + const runId = await randomId(); + const logger = new RunLogger(id, runId); + + const input: z.infer = [{ + role: "user", + content: "What is the current date?" + }]; + const msgs: z.infer = [...input]; + + try { + const renderer = new StreamRenderer(); + + for await (const step of workflow.steps) { + const node = step.type === "agent" ? new AgentNode(step.id) : loadFunction(step.id); + const messageBuilder = new StreamStepMessageBuilder(); + for await (const event of node.execute(msgs)) { + // console.log(" - event", JSON.stringify(event)); + messageBuilder.ingest(event); + renderer.render(event); + } + const msg = messageBuilder.get(); + logger.log(msg); + msgs.push(msg); + } + } finally { + logger.close(); + } + + console.log('\n\n', JSON.stringify(msgs, null, 2)); +} + +async function streamEventTest() { + const { fullStream } = streamText({ + model: openai("gpt-5"), + system: "You are a helpful assistant that reasons about the world. Provide a reason for invoking any tools", + messages: [{ role: "user", content: "what is the current date and time?" }], + tools: { + getDate: { + description: "Get the current date", + inputSchema: z.object({ + format: z.enum(["long", "short"]).default("long"), + }), + }, + getTime: { + description: "Get the current time", + inputSchema: z.object({ + format: z.enum(["long", "short"]).default("long"), + }), + }, + }, + }); + + const renderer = new StreamRenderer(); + for await (const event of fullStream) { + renderer.render(event as any); + } +} + +// streamEventTest(); + +executeWorkflow("example_workflow"); \ No newline at end of file diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts new file mode 100644 index 00000000..1f16f481 --- /dev/null +++ b/apps/cli/src/application/config/config.ts @@ -0,0 +1,16 @@ +import path from "path"; +import fs from "fs"; +import { McpServerConfig } from "../entities/mcp.js"; +import { z } from "zod"; + +export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat" + + +function loadMcpServerConfig(): z.infer { + const configPath = path.join(WorkDir, "config", "mcp.json"); + const config = fs.readFileSync(configPath, "utf8"); + return McpServerConfig.parse(JSON.parse(config)); +} + +const { mcpServers } = loadMcpServerConfig(); +export const McpServers = mcpServers; \ No newline at end of file diff --git a/apps/cli/src/application/entities/agent.ts b/apps/cli/src/application/entities/agent.ts new file mode 100644 index 00000000..a8d493e9 --- /dev/null +++ b/apps/cli/src/application/entities/agent.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +export const Agent = z.object({ + name: z.string(), + model: z.string(), + description: z.string(), + instructions: z.string(), +}); diff --git a/apps/cli/src/application/entities/mcp.ts b/apps/cli/src/application/entities/mcp.ts new file mode 100644 index 00000000..77aa63d3 --- /dev/null +++ b/apps/cli/src/application/entities/mcp.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const McpServerConfig = z.object({ + mcpServers: z.array(z.object({ + name: z.string(), + url: z.string(), + })), +}); \ No newline at end of file diff --git a/apps/cli/src/application/entities/message.ts b/apps/cli/src/application/entities/message.ts new file mode 100644 index 00000000..ef937e51 --- /dev/null +++ b/apps/cli/src/application/entities/message.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const TextPart = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +export const ReasoningPart = z.object({ + type: z.literal("reasoning"), + text: z.string(), +}); + +export const ToolCallPart = z.object({ + type: z.literal("tool-call"), + toolCallId: z.string(), + toolName: z.string(), + arguments: z.string(), +}); + +export const AssistantContentPart = z.union([ + TextPart, + ReasoningPart, + ToolCallPart, +]); + +export const UserMessage = z.object({ + role: z.literal("user"), + content: z.string(), +}); + +export const AssistantMessage = z.object({ + role: z.literal("assistant"), + content: z.union([ + z.string(), + z.array(AssistantContentPart), + ]), +}); + +export const SystemMessage = z.object({ + role: z.literal("system"), + content: z.string(), +}); + +export const ToolMessage = z.object({ + role: z.literal("tool"), + content: z.string(), + toolCallId: z.string(), + toolName: z.string(), +}); + +export const Message = z.discriminatedUnion("role", [ + AssistantMessage, + SystemMessage, + ToolMessage, + UserMessage, +]); + +export const MessageList = z.array(Message); \ No newline at end of file diff --git a/apps/cli/src/application/entities/stream-event.ts b/apps/cli/src/application/entities/stream-event.ts new file mode 100644 index 00000000..8898599f --- /dev/null +++ b/apps/cli/src/application/entities/stream-event.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export const ReasoningStartEvent = z.object({ + type: z.literal("reasoning-start"), +}); + +export const ReasoningDeltaEvent = z.object({ + type: z.literal("reasoning-delta"), + delta: z.string(), +}); + +export const ReasoningEndEvent = z.object({ + type: z.literal("reasoning-end"), +}); + +export const TextStartEvent = z.object({ + type: z.literal("text-start"), +}); + +export const TextDeltaEvent = z.object({ + type: z.literal("text-delta"), + delta: z.string(), +}); + +export const TextEndEvent = z.object({ + type: z.literal("text-end"), +}); + +export const ToolCallEvent = z.object({ + type: z.literal("tool-call"), + toolCallId: z.string(), + toolName: z.string(), + input: z.any(), +}); + +export const UsageEvent = z.object({ + type: z.literal("usage"), + usage: z.object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional(), + reasoningTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + }), +}); + +export const StreamEvent = z.union([ + ReasoningStartEvent, + ReasoningDeltaEvent, + ReasoningEndEvent, + TextStartEvent, + TextDeltaEvent, + TextEndEvent, + ToolCallEvent, + UsageEvent, +]); \ No newline at end of file diff --git a/apps/cli/src/application/entities/workflow.ts b/apps/cli/src/application/entities/workflow.ts new file mode 100644 index 00000000..9304371e --- /dev/null +++ b/apps/cli/src/application/entities/workflow.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const AgentStep = z.object({ + type: z.literal("agent"), + id: z.string(), +}); + +const FunctionStep = z.object({ + type: z.literal("function"), + id: z.string(), +}); + +const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]); + +export const Workflow = z.object({ + name: z.string(), + description: z.string(), + steps: z.array(Step), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); \ No newline at end of file diff --git a/apps/cli/src/application/functions/get_date.ts b/apps/cli/src/application/functions/get_date.ts new file mode 100644 index 00000000..9f421086 --- /dev/null +++ b/apps/cli/src/application/functions/get_date.ts @@ -0,0 +1,16 @@ +import { Node, NodeOutputT } from "../nodes/node.js"; + +export class GetDate implements Node { + async* execute(): NodeOutputT { + yield { + type: "text-start", + }; + yield { + type: "text-delta", + delta: 'The current date is ' + new Date().toISOString(), + }; + yield { + type: "text-end", + }; + } +} \ No newline at end of file diff --git a/apps/cli/src/application/lib/mcp.ts b/apps/cli/src/application/lib/mcp.ts new file mode 100644 index 00000000..041710f5 --- /dev/null +++ b/apps/cli/src/application/lib/mcp.ts @@ -0,0 +1,31 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; + +export async function getMcpClient(serverUrl: string, serverName: string): Promise { + let client: Client | undefined = undefined; + const baseUrl = new URL(serverUrl); + + // Try to connect using Streamable HTTP transport + try { + client = new Client({ + name: 'streamable-http-client', + version: '1.0.0' + }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`); + return client; + } catch (error) { + // If that fails with a 4xx error, try the older SSE transport + console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`); + client = new Client({ + name: 'sse-client', + version: '1.0.0' + }); + const sseTransport = new SSEClientTransport(baseUrl); + await client.connect(sseTransport); + console.log(`[MCP] Connected using SSE transport to ${serverName}`); + return client; + } +} diff --git a/apps/cli/src/application/lib/random-id.ts b/apps/cli/src/application/lib/random-id.ts new file mode 100644 index 00000000..8f75b8da --- /dev/null +++ b/apps/cli/src/application/lib/random-id.ts @@ -0,0 +1,7 @@ +import { customAlphabet } from 'nanoid'; +const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz-'; +const nanoid = customAlphabet(alphabet, 7); + +export async function randomId(): Promise { + return nanoid(); +} \ No newline at end of file diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts new file mode 100644 index 00000000..a8d69d7b --- /dev/null +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; +import { StreamEvent } from "../entities/stream-event.js"; + +export interface StreamRendererOptions { + showHeaders?: boolean; + dimReasoning?: boolean; + jsonIndent?: number; + truncateJsonAt?: number; +} + +export class StreamRenderer { + private options: Required; + private reasoningActive = false; + private textActive = false; + + constructor(options?: StreamRendererOptions) { + this.options = { + showHeaders: true, + dimReasoning: true, + jsonIndent: 2, + truncateJsonAt: 500, + ...options, + }; + } + + render(event: z.infer) { + switch (event.type) { + case "reasoning-start": + this.onReasoningStart(); + break; + case "reasoning-delta": + this.onReasoningDelta(event.delta); + break; + case "reasoning-end": + this.onReasoningEnd(); + break; + case "text-start": + this.onTextStart(); + break; + case "text-delta": + this.onTextDelta(event.delta); + break; + case "text-end": + this.onTextEnd(); + break; + case "tool-call": + this.onToolCall(event.toolCallId, event.toolName, event.input); + break; + case "usage": + this.onUsage(event.usage); + break; + } + } + + private onReasoningStart() { + if (this.reasoningActive) return; + this.reasoningActive = true; + if (this.options.showHeaders) { + this.write("\n"); + this.write(this.dim("Reasoning: ")); + } + } + + private onReasoningDelta(delta: string) { + if (!this.reasoningActive) this.onReasoningStart(); + this.write(this.options.dimReasoning ? this.dim(delta) : delta); + } + + private onReasoningEnd() { + if (!this.reasoningActive) return; + this.reasoningActive = false; + this.write(this.dim("\n")); + } + + private onTextStart() { + if (this.textActive) return; + this.textActive = true; + if (this.options.showHeaders) { + this.write("\n"); + this.write(this.bold("Assistant: ")); + } + } + + private onTextDelta(delta: string) { + if (!this.textActive) this.onTextStart(); + this.write(delta); + } + + private onTextEnd() { + if (!this.textActive) return; + this.textActive = false; + this.write("\n"); + } + + private onToolCall(toolCallId: string, toolName: string, input: unknown) { + const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent)); + this.write("\n"); + this.write(this.cyan(`→ Tool call ${toolName} (${toolCallId})`)); + this.write("\n"); + this.write(this.dim(this.indent(inputStr))); + this.write("\n"); + } + + private onUsage(usage: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cachedInputTokens?: number; + }) { + const parts: string[] = []; + if (usage.inputTokens !== undefined) parts.push(`input=${usage.inputTokens}`); + if (usage.outputTokens !== undefined) parts.push(`output=${usage.outputTokens}`); + if (usage.reasoningTokens !== undefined) parts.push(`reasoning=${usage.reasoningTokens}`); + if (usage.cachedInputTokens !== undefined) parts.push(`cached=${usage.cachedInputTokens}`); + if (usage.totalTokens !== undefined) parts.push(`total=${usage.totalTokens}`); + const line = parts.join(", "); + this.write(this.dim(`\nUsage: ${line}\n`)); + } + + // Formatting helpers + private write(text: string) { + process.stdout.write(text); + } + + private indent(text: string): string { + return text + .split("\n") + .map((line) => (line.length ? ` ${line}` : line)) + .join("\n"); + } + + private truncate(text: string): string { + if (text.length <= this.options.truncateJsonAt) return text; + return text.slice(0, this.options.truncateJsonAt) + "…"; + } + + private bold(text: string): string { + return "\x1b[1m" + text + "\x1b[0m"; + } + + private dim(text: string): string { + return "\x1b[2m" + text + "\x1b[0m"; + } + + private cyan(text: string): string { + return "\x1b[36m" + text + "\x1b[0m"; + } +} + + diff --git a/apps/cli/src/application/nodes/agent.ts b/apps/cli/src/application/nodes/agent.ts new file mode 100644 index 00000000..4e0be41a --- /dev/null +++ b/apps/cli/src/application/nodes/agent.ts @@ -0,0 +1,130 @@ +import { Message } from "../entities/message.js"; +import { z } from "zod"; +import { Node, NodeInputT, NodeOutputT } from "./node.js"; +import { openai } from "@ai-sdk/openai"; +import { generateText, ModelMessage, stepCountIs, streamText } from "ai"; +import { Agent } from "../entities/agent.js"; +import { WorkDir } from "../config/config.js"; +import fs from "fs"; +import path from "path"; + +function convertFromMessages(messages: z.infer[]): ModelMessage[] { + const result: ModelMessage[] = []; + for (const msg of messages) { + switch (msg.role) { + case "assistant": + if (typeof msg.content === 'string') { + result.push({ + role: "assistant", + content: msg.content, + }); + } else { + result.push({ + role: "assistant", + content: msg.content.map(part => { + switch (part.type) { + case 'text': + return part; + case 'reasoning': + return part; + case 'tool-call': + return { + type: 'tool-call', + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.arguments, + }; + } + }), + }); + } + break; + case "system": + result.push({ + role: "system", + content: msg.content, + }); + break; + case "user": + result.push({ + role: "user", + content: msg.content, + }); + break; + } + } + return result; +} + +export class AgentNode implements Node { + private id: string; + + constructor(id: string) { + this.id = id; + } + + private loadAgent(id: string): z.infer { + const agentPath = path.join(WorkDir, "agents", `${id}.json`); + const agent = fs.readFileSync(agentPath, "utf8"); + return Agent.parse(JSON.parse(agent)); + } + + async* execute(input: NodeInputT): NodeOutputT { + const agent = this.loadAgent(this.id); + const { fullStream } = await streamText({ + model: openai(agent.model), + messages: convertFromMessages(input), + system: agent.instructions, + stopWhen: stepCountIs(1), + }); + + for await (const event of fullStream) { + switch (event.type) { + case "reasoning-start": + yield { + type: "reasoning-start", + }; + break; + case "reasoning-delta": + yield { + type: "reasoning-delta", + delta: event.text, + }; + break; + case "reasoning-end": + yield { + type: "reasoning-end", + }; + break; + case "text-start": + yield { + type: "text-start", + }; + break; + case "text-delta": + yield { + type: "text-delta", + delta: event.text, + }; + break; + case "tool-call": + yield { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + }; + break; + case "finish": + yield { + type: "usage", + usage: event.totalUsage, + }; + break; + default: + // console.warn("Unknown event type", event); + continue; + } + } + } +} \ No newline at end of file diff --git a/apps/cli/src/application/nodes/node.ts b/apps/cli/src/application/nodes/node.ts new file mode 100644 index 00000000..5d5d3186 --- /dev/null +++ b/apps/cli/src/application/nodes/node.ts @@ -0,0 +1,10 @@ +import { MessageList } from "../entities/message.js"; +import { StreamEvent } from "../entities/stream-event.js"; +import { z } from "zod"; + +export type NodeInputT = z.infer; +export type NodeOutputT = AsyncGenerator, void, unknown>; + +export interface Node { + execute(input: NodeInputT): NodeOutputT; +} \ No newline at end of file diff --git a/apps/cli/src/application/registry/functions.ts b/apps/cli/src/application/registry/functions.ts new file mode 100644 index 00000000..3b131c30 --- /dev/null +++ b/apps/cli/src/application/registry/functions.ts @@ -0,0 +1,6 @@ +import { GetDate } from "../functions/get_date.js"; +import { Node } from "../nodes/node.js"; + +export const FunctionsRegistry: Record = { + get_date: new GetDate(), +} as const; \ No newline at end of file diff --git a/apps/cli/src/application/registry/tools.ts b/apps/cli/src/application/registry/tools.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/cli/todo.md b/apps/cli/todo.md new file mode 100644 index 00000000..9c97afd3 --- /dev/null +++ b/apps/cli/todo.md @@ -0,0 +1,15 @@ +runtime +--- +o stream out responses +o terminal logging +o file logging +- accept initial user input from CLI +- mcp tool calls (http + stdio) +- human input support +- bash tool support +- cli wrapper (node commander) + + +rowboat agent +--- +- create agent \ No newline at end of file diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000..1fad84d4 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,20 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "module": "nodenext", + "target": "esnext", + "lib": ["esnext"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "paths": { + "@/*": [ + "./src/*" + ] + } + } +} From 055dda35b99a557dd4f7ab22c1364fe5edf362d9 Mon Sep 17 00:00:00 2001 From: Arjun Date: Mon, 3 Nov 2025 21:50:17 +0530 Subject: [PATCH 02/38] arjun: command executor function --- .../src/application/lib/command-executor.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/cli/src/application/lib/command-executor.ts diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts new file mode 100644 index 00000000..c8ba6939 --- /dev/null +++ b/apps/cli/src/application/lib/command-executor.ts @@ -0,0 +1,80 @@ +import { exec, execSync } from 'child_process'; +import { promisify } from 'util'; + +const execPromise = promisify(exec); + +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Executes an arbitrary shell command + * @param command - The command to execute (e.g., "cat abc.txt | grep 'abc@gmail.com'") + * @param options - Optional execution options + * @returns Promise with stdout, stderr, and exit code + */ +export async function executeCommand( + command: string, + options?: { + cwd?: string; + timeout?: number; // timeout in milliseconds + maxBuffer?: number; // max buffer size in bytes + } +): Promise { + try { + const { stdout, stderr } = await execPromise(command, { + cwd: options?.cwd, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB + shell: '/bin/sh', // use sh for cross-platform compatibility + }); + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: 0, + }; + } catch (error: any) { + // exec throws an error if the command fails or times out + return { + stdout: error.stdout?.trim() || '', + stderr: error.stderr?.trim() || error.message, + exitCode: error.code || 1, + }; + } +} + +/** + * Executes a command synchronously (blocking) + * Use with caution - prefer executeCommand for async execution + */ +export function executeCommandSync( + command: string, + options?: { + cwd?: string; + timeout?: number; + } +): CommandResult { + try { + const stdout = execSync(command, { + cwd: options?.cwd, + timeout: options?.timeout, + encoding: 'utf-8', + shell: '/bin/sh', + }); + + return { + stdout: stdout.trim(), + stderr: '', + exitCode: 0, + }; + } catch (error: any) { + return { + stdout: error.stdout?.toString().trim() || '', + stderr: error.stderr?.toString().trim() || error.message, + exitCode: error.status || 1, + }; + } +} From 4310b1d45d4df0503a38729521edd51c9abeed67 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 30 Oct 2025 16:09:19 +0800 Subject: [PATCH 03/38] First version copilot: - basic llm call that can perform CRUD actions over dummy workflow json files --- apps/cli/.rowboat/.gitignore | 2 + apps/cli/package.json | 3 +- apps/cli/src/application/assistant/README.md | 6 + apps/cli/src/application/assistant/USAGE.md | 12 ++ .../application/assistant/agents/service.ts | 37 ++++++ apps/cli/src/application/assistant/chat.ts | 105 ++++++++++++++++++ .../src/application/assistant/mcp/service.ts | 24 ++++ .../application/assistant/services/storage.ts | 70 ++++++++++++ .../assistant/workflows/service.ts | 44 ++++++++ apps/cli/src/application/config/config.ts | 19 +++- apps/cli/src/x.ts | 9 ++ 11 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 apps/cli/.rowboat/.gitignore create mode 100644 apps/cli/src/application/assistant/README.md create mode 100644 apps/cli/src/application/assistant/USAGE.md create mode 100644 apps/cli/src/application/assistant/agents/service.ts create mode 100644 apps/cli/src/application/assistant/chat.ts create mode 100644 apps/cli/src/application/assistant/mcp/service.ts create mode 100644 apps/cli/src/application/assistant/services/storage.ts create mode 100644 apps/cli/src/application/assistant/workflows/service.ts create mode 100644 apps/cli/src/x.ts diff --git a/apps/cli/.rowboat/.gitignore b/apps/cli/.rowboat/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/apps/cli/.rowboat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/apps/cli/package.json b/apps/cli/package.json index 165932a0..8a16c38f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "rm -rf dist && tsc" + "build": "rm -rf dist && tsc", + "copilot": "npm run build && node dist/x.js" }, "keywords": [], "author": "", diff --git a/apps/cli/src/application/assistant/README.md b/apps/cli/src/application/assistant/README.md new file mode 100644 index 00000000..ebcbf861 --- /dev/null +++ b/apps/cli/src/application/assistant/README.md @@ -0,0 +1,6 @@ +Rowboat Copilot (demo) + +- Entry point: `npm run copilot` (runs `src/x.ts` after building) +- Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows` +- Uses existing zod schemas for validation; errors bubble up plainly for easy debugging +- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp` diff --git a/apps/cli/src/application/assistant/USAGE.md b/apps/cli/src/application/assistant/USAGE.md new file mode 100644 index 00000000..21a25779 --- /dev/null +++ b/apps/cli/src/application/assistant/USAGE.md @@ -0,0 +1,12 @@ +Quick start + +1. `cd rowboat-V2/apps/cli` +2. `export OPENAI_API_KEY=...` +3. `npm run copilot` + +Example prompts once running: +- `list my workflows` +- `show workflow example_workflow` +- `create a workflow demo that calls function get_date` +- `add an agent step default_assistant to demo` +- `delete the demo workflow` diff --git a/apps/cli/src/application/assistant/agents/service.ts b/apps/cli/src/application/assistant/agents/service.ts new file mode 100644 index 00000000..f18022c0 --- /dev/null +++ b/apps/cli/src/application/assistant/agents/service.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { Agent } from "../../entities/agent.js"; +import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; + +export type AgentId = string; + +export function listAgents(): AgentId[] { + return listJson("agents"); +} + +export function getAgent(id: AgentId): z.infer | undefined { + const raw = readJson("agents", id); + if (!raw) return undefined; + return Agent.parse(raw); +} + +export function upsertAgent( + id: AgentId, + value: Partial> +): z.infer { + const existing = readJson("agents", id) as Partial> | undefined; + const merged = { + name: id, + model: "openai:gpt-4o-mini", + description: "", + instructions: "", + ...(existing ?? {}), + ...value, + } satisfies Partial>; + const parsed = Agent.parse(merged); + writeJson("agents", id, parsed); + return parsed; +} + +export function deleteAgent(id: AgentId): boolean { + return deleteJson("agents", id); +} diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts new file mode 100644 index 00000000..d6de810d --- /dev/null +++ b/apps/cli/src/application/assistant/chat.ts @@ -0,0 +1,105 @@ +import readline from "readline"; +import { z } from "zod"; +import { openai } from "@ai-sdk/openai"; +import { generateObject } from "ai"; +import { Workflow } from "../../application/entities/workflow.js"; +import { listWorkflows, getWorkflow, upsertWorkflow, deleteWorkflow } from "./workflows/service.js"; + +const ChatCommand = z.object({ + action: z.enum([ + "help", + "list_workflows", + "get_workflow", + "create_workflow", + "update_workflow", + "delete_workflow", + "unknown", + ]), + id: z.string().optional(), + updates: Workflow.partial().optional(), +}); + +type ChatCommandT = z.infer; + +const systemPrompt = ` +You are a CLI assistant that converts the user's natural language into a JSON command for managing workflows. + +Rules: +- Only output JSON matching the provided schema. No extra commentary. +- Choose the most appropriate action from: help, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, unknown. +- For actions that need an id (get/update/delete/create), set "id" to the workflow identifier (e.g. "example_workflow"). +- For create/update, include only provided fields in "updates". If not provided, omit. +- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }. +- If the request is ambiguous, set action to "unknown". +`; + +async function interpret(input: string): Promise { + const { object } = await generateObject({ + model: openai("gpt-4.1"), + system: systemPrompt, + prompt: input, + schema: ChatCommand, + }); + return object; +} + +async function execute(cmd: ChatCommandT): Promise { + switch (cmd.action) { + case "help": + return { + usage: [ + "Examples:", + "- list workflows", + "- show workflow example_workflow", + "- create workflow demo with one step calling function get_date", + "- update workflow demo: add agent step default_assistant", + "- delete workflow demo", + ], + }; + case "list_workflows": + return { items: listWorkflows() }; + case "get_workflow": + if (!cmd.id) return { error: "id required" }; + return getWorkflow(cmd.id) ?? null; + case "create_workflow": + if (!cmd.id) return { error: "id required" }; + return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + case "update_workflow": + if (!cmd.id) return { error: "id required" }; + return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + case "delete_workflow": + if (!cmd.id) return { error: "id required" }; + return { deleted: deleteWorkflow(cmd.id) }; + case "unknown": + return { error: "Could not determine intent. Try again or ask for help." }; + } +} + +export async function startCopilot(): Promise { + if (!process.env.OPENAI_API_KEY) { + console.error("OPENAI_API_KEY is not set. Please export it to use chat."); + process.exitCode = 1; + return; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + console.log("Rowboat Copilot (type 'exit' to quit)"); + + const ask = () => rl.question("> ", async (line) => { + if (!line || line.trim().toLowerCase() === "exit") { + rl.close(); + return; + } + try { + const cmd = await interpret(line); + console.log("\n=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); + const result = await execute(cmd); + console.log("\n=== Result ===\n" + JSON.stringify(result, null, 2) + "\n"); + } catch (err) { + console.error("Error:", (err as Error).message); + } + ask(); + }); + + ask(); +} diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts new file mode 100644 index 00000000..c9d6da44 --- /dev/null +++ b/apps/cli/src/application/assistant/mcp/service.ts @@ -0,0 +1,24 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import { McpServerConfig } from "../../entities/mcp.js"; +import { ensureBaseDirs, getStoragePaths } from "../services/storage.js"; + +export function mcpConfigPath(): string { + const base = getStoragePaths(); + ensureBaseDirs(base); + return path.join(base.workDir, "mcp", "servers.json"); +} + +export function readMcpConfig(): z.infer { + const p = mcpConfigPath(); + if (!fs.existsSync(p)) return { mcpServers: [] }; + const raw = fs.readFileSync(p, "utf8"); + return McpServerConfig.parse(JSON.parse(raw)); +} + +export function writeMcpConfig(value: z.infer): void { + const p = mcpConfigPath(); + const parsed = McpServerConfig.parse(value); + fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8"); +} diff --git a/apps/cli/src/application/assistant/services/storage.ts b/apps/cli/src/application/assistant/services/storage.ts new file mode 100644 index 00000000..ccf14c44 --- /dev/null +++ b/apps/cli/src/application/assistant/services/storage.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +export type DirKind = "workflows" | "agents" | "mcp"; + +export interface StoragePaths { + appRoot: string; + workDir: string; // .rowboat +} + +const defaultPaths: StoragePaths = (() => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const appRoot = path.resolve(__dirname, "../../../../"); + const workDir = path.join(appRoot, ".rowboat"); + return { appRoot, workDir }; +})(); + +export function getStoragePaths(): StoragePaths { + return defaultPaths; +} + +export function ensureBaseDirs(base: StoragePaths = defaultPaths) { + const ensure = (p: string) => { + if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); + }; + ensure(base.workDir); + ensure(path.join(base.workDir, "workflows")); + ensure(path.join(base.workDir, "agents")); + ensure(path.join(base.workDir, "mcp")); +} + +export function dirFor(kind: DirKind, base: StoragePaths = defaultPaths): string { + switch (kind) { + case "workflows": + return path.join(base.workDir, "workflows"); + case "agents": + return path.join(base.workDir, "agents"); + case "mcp": + return path.join(base.workDir, "mcp"); + } +} + +export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): string[] { + const d = dirFor(kind, base); + if (!fs.existsSync(d)) return []; + return fs + .readdirSync(d) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); +} + +export function readJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): T | undefined { + const p = path.join(dirFor(kind, base), `${id}.json`); + if (!fs.existsSync(p)) return undefined; + const raw = fs.readFileSync(p, "utf8"); + return JSON.parse(raw) as T; +} + +export function writeJson(kind: DirKind, id: string, value: unknown, base: StoragePaths = defaultPaths): void { + const p = path.join(dirFor(kind, base), `${id}.json`); + fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +export function deleteJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): boolean { + const p = path.join(dirFor(kind, base), `${id}.json`); + if (!fs.existsSync(p)) return false; + fs.rmSync(p); + return true; +} diff --git a/apps/cli/src/application/assistant/workflows/service.ts b/apps/cli/src/application/assistant/workflows/service.ts new file mode 100644 index 00000000..d01d0796 --- /dev/null +++ b/apps/cli/src/application/assistant/workflows/service.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { Workflow } from "../../entities/workflow.js"; +import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; + +export type WorkflowId = string; + +export function listWorkflows(): WorkflowId[] { + return listJson("workflows"); +} + +export function getWorkflow(id: WorkflowId): z.infer | undefined { + const raw = readJson("workflows", id); + if (!raw) return undefined; + return Workflow.parse(raw); +} + +export function upsertWorkflow( + id: WorkflowId, + value: Partial> +): z.infer { + const existing = readJson("workflows", id) as Partial> | undefined; + const now = new Date().toISOString(); + + const defaults: Partial> = { + name: id, + description: "", + steps: [], + createdAt: existing?.createdAt ?? now, + }; + const merged = { + ...defaults, + ...(existing ?? {}), + ...value, + updatedAt: now, + } satisfies Partial>; + + const parsed = Workflow.parse(merged); + writeJson("workflows", id, parsed); + return parsed; +} + +export function deleteWorkflow(id: WorkflowId): boolean { + return deleteJson("workflows", id); +} diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 1f16f481..2692b300 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -1,16 +1,29 @@ import path from "path"; import fs from "fs"; +import { fileURLToPath } from "url"; import { McpServerConfig } from "../entities/mcp.js"; import { z } from "zod"; -export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat" +// Resolve app root relative to compiled file location (dist/...) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const AppRoot = path.resolve(__dirname, "../../.."); +export const WorkDir = path.join(AppRoot, ".rowboat"); +function ensureDirs() { + const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; + ensure(WorkDir); + ensure(path.join(WorkDir, "workflows")); + ensure(path.join(WorkDir, "agents")); + ensure(path.join(WorkDir, "mcp")); +} function loadMcpServerConfig(): z.infer { - const configPath = path.join(WorkDir, "config", "mcp.json"); + ensureDirs(); + const configPath = path.join(WorkDir, "mcp", "servers.json"); + if (!fs.existsSync(configPath)) return { mcpServers: [] }; const config = fs.readFileSync(configPath, "utf8"); return McpServerConfig.parse(JSON.parse(config)); } const { mcpServers } = loadMcpServerConfig(); -export const McpServers = mcpServers; \ No newline at end of file +export const McpServers = mcpServers; diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts new file mode 100644 index 00000000..cf823710 --- /dev/null +++ b/apps/cli/src/x.ts @@ -0,0 +1,9 @@ +import { ensureBaseDirs } from "./application/assistant/services/storage.js"; +import { startCopilot } from "./application/assistant/chat.js"; + +ensureBaseDirs(); + +startCopilot().catch((err) => { + console.error("Failed to run copilot:", err); + process.exitCode = 1; +}); From 0eda81b33cc767418e29b41d8a846b233abdae27 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 4 Nov 2025 16:39:23 +0530 Subject: [PATCH 04/38] Enhance Rowboat Copilot functionality: - Expanded the system prompt to support a wider range of actions, including general chat and agent management. - Implemented conversational memory, allowing the assistant to maintain context and respond in natural language. - Updated the interpret function to accept conversation history, improving command parsing. - Enhanced the rendering of assistant responses to include command outcomes and maintain conversational flow. - Updated documentation to reflect new features, including conversational context and debug mode for inspecting raw commands. --- apps/cli/src/application/assistant/README.md | 1 + apps/cli/src/application/assistant/USAGE.md | 4 + apps/cli/src/application/assistant/chat.ts | 219 +++++--- .../cli/src/application/assistant/commands.ts | 501 ++++++++++++++++++ 4 files changed, 661 insertions(+), 64 deletions(-) create mode 100644 apps/cli/src/application/assistant/commands.ts diff --git a/apps/cli/src/application/assistant/README.md b/apps/cli/src/application/assistant/README.md index ebcbf861..4c776c26 100644 --- a/apps/cli/src/application/assistant/README.md +++ b/apps/cli/src/application/assistant/README.md @@ -3,4 +3,5 @@ Rowboat Copilot (demo) - Entry point: `npm run copilot` (runs `src/x.ts` after building) - Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows` - Uses existing zod schemas for validation; errors bubble up plainly for easy debugging +- Maintains conversational memory within a session and replies in natural language (append `--debug` or set `COPILOT_DEBUG=1` to view raw JSON commands) - Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp` diff --git a/apps/cli/src/application/assistant/USAGE.md b/apps/cli/src/application/assistant/USAGE.md index 21a25779..0181a696 100644 --- a/apps/cli/src/application/assistant/USAGE.md +++ b/apps/cli/src/application/assistant/USAGE.md @@ -10,3 +10,7 @@ Example prompts once running: - `create a workflow demo that calls function get_date` - `add an agent step default_assistant to demo` - `delete the demo workflow` + +While the session is open the copilot keeps conversational context, so you can ask follow-ups such as “what was the first thing I asked?” or “add that step again”. Responses are natural language summaries of the structured actions it performs. + +Need to inspect the underlying JSON command/results? Run in debug mode with `npm run copilot -- --debug` (or set `COPILOT_DEBUG=1`) to keep the raw interpreter output visible. diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index d6de810d..e96a23ee 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -1,80 +1,149 @@ import readline from "readline"; -import { z } from "zod"; import { openai } from "@ai-sdk/openai"; -import { generateObject } from "ai"; -import { Workflow } from "../../application/entities/workflow.js"; -import { listWorkflows, getWorkflow, upsertWorkflow, deleteWorkflow } from "./workflows/service.js"; +import { generateObject, streamText } from "ai"; +import type { CoreMessage } from "ai"; +import { + ChatCommand, + ChatCommandT, + CommandOutcome, + executeCommand, +} from "./commands.js"; -const ChatCommand = z.object({ - action: z.enum([ - "help", - "list_workflows", - "get_workflow", - "create_workflow", - "update_workflow", - "delete_workflow", - "unknown", - ]), - id: z.string().optional(), - updates: Workflow.partial().optional(), -}); - -type ChatCommandT = z.infer; +type ConversationMessage = { + role: "user" | "assistant"; + content: string; +}; const systemPrompt = ` -You are a CLI assistant that converts the user's natural language into a JSON command for managing workflows. +You are a general-purpose CLI copilot that converts the user's natural language into structured commands the Rowboat assistant runtime can execute, and you can also hold a regular conversation when no command fits. Rules: - Only output JSON matching the provided schema. No extra commentary. -- Choose the most appropriate action from: help, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, unknown. -- For actions that need an id (get/update/delete/create), set "id" to the workflow identifier (e.g. "example_workflow"). -- For create/update, include only provided fields in "updates". If not provided, omit. +- Select the most appropriate action from: help, general_chat, list_workflows, get_workflow, describe_workflows, create_workflow, update_workflow, delete_workflow, list_agents, get_agent, create_agent, update_agent, delete_agent, list_mcp_servers, add_mcp_server, remove_mcp_server, run_workflow, unknown. +- Use describe_workflows with { scope: "all" } to show every workflow, or provide specific ids when the user names particular workflows (including pronouns like "them" or "those" referring to previously listed workflows). +- For actions that need an id (workflow/agent), set "id" to the identifier (e.g. "example_workflow"). +- For create/update actions, only include provided fields in "updates". - Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }. +- Agent shape reminder: { name: string, model: string, description: string, instructions: string }. +- MCP server shape reminder: { name: string, url: string }. - If the request is ambiguous, set action to "unknown". +- If the user is just chatting or asking for general help or explanations, use action "general_chat" with their full prompt in "query". `; -async function interpret(input: string): Promise { - const { object } = await generateObject({ - model: openai("gpt-4.1"), - system: systemPrompt, - prompt: input, - schema: ChatCommand, - }); - return object; +const responseSystemPrompt = ` +You are Skipper, the Rowboat CLI copilot. You maintain an ongoing conversation, remember prior questions, run commands when requested, and give helpful free-form answers when a general reply is appropriate. + +Guidelines: +- Respond in natural language with short, helpful paragraphs or bullet lists when useful. +- Summarise command results plainly (lists, confirmations, errors) and mention next steps when appropriate. +- If a command could not be inferred (action "unknown"), clarify what additional detail is needed or answer the query directly using the conversation history when possible. +- Use the conversation history to answer memory questions (for example "what was the first question I asked?"). +- Avoid repeating the raw JSON command or result unless explicitly asked; focus on what the outcome means. +- Deliver everything requested in one response. Do not say you'll follow up later—include all available details right away. +- For general_chat actions, respond directly to the user's query with the best answer you can provide. +`; + +function buildMessageHistory(history: ConversationMessage[]): CoreMessage[] { + return history.map((message) => ({ + role: message.role, + content: message.content, + })); } -async function execute(cmd: ChatCommandT): Promise { - switch (cmd.action) { - case "help": - return { - usage: [ - "Examples:", - "- list workflows", - "- show workflow example_workflow", - "- create workflow demo with one step calling function get_date", - "- update workflow demo: add agent step default_assistant", - "- delete workflow demo", - ], - }; - case "list_workflows": - return { items: listWorkflows() }; - case "get_workflow": - if (!cmd.id) return { error: "id required" }; - return getWorkflow(cmd.id) ?? null; - case "create_workflow": - if (!cmd.id) return { error: "id required" }; - return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - case "update_workflow": - if (!cmd.id) return { error: "id required" }; - return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - case "delete_workflow": - if (!cmd.id) return { error: "id required" }; - return { deleted: deleteWorkflow(cmd.id) }; - case "unknown": - return { error: "Could not determine intent. Try again or ask for help." }; +async function interpret(input: string, history: ConversationMessage[]): Promise { + const stopSpinner = startSpinner("Analyzing…", { persist: false }); + const conversation: CoreMessage[] = [ + { role: "system", content: systemPrompt }, + ...buildMessageHistory(history), + { role: "user", content: input }, + ]; + + try { + const { object } = await generateObject({ + model: openai("gpt-4.1"), + messages: conversation, + schema: ChatCommand, + }); + return object; + } finally { + stopSpinner(); } } +function startSpinner( + label: string, + options: { persist?: boolean } = {} +): (finalMessage?: string) => void { + const { persist = true } = options; + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"]; + let index = 0; + const render = () => { + const frame = frames[index]; + index = (index + 1) % frames.length; + process.stdout.write(`\r${frame} ${label}`); + }; + render(); + const timer = setInterval(render, 80); + return (finalMessage?: string) => { + clearInterval(timer); + const doneFrame = frames[(index + frames.length - 1) % frames.length]; + const message = finalMessage ?? "done"; + const clearWidth = doneFrame.length + label.length + (persist ? message.length + 3 : 2); + const clear = " ".repeat(clearWidth); + process.stdout.write(`\r${clear}`); + if (persist) { + process.stdout.write(`\r${doneFrame} ${label} ${message}\n`); + } else { + process.stdout.write("\r"); + } + }; +} + +async function renderAssistantResponse( + input: string, + cmd: ChatCommandT, + outcome: CommandOutcome, + history: ConversationMessage[] +): Promise { + const condensedCommand = JSON.stringify(cmd, null, 2); + const condensedResult = JSON.stringify(outcome, null, 2); + + const { textStream } = await streamText({ + model: openai("gpt-4.1"), + messages: [ + { role: "system", content: responseSystemPrompt }, + ...buildMessageHistory(history), + { + role: "user", + content: [ + `Most recent request: ${input}`, + `Interpreter output:\n${condensedCommand}`, + `Command result:\n${condensedResult}`, + ].join("\n\n"), + }, + ], + }); + + let final = ""; + for await (const textChunk of textStream as AsyncIterable) { + const chunk = + typeof textChunk === "string" + ? textChunk + : typeof (textChunk as { value?: string }).value === "string" + ? (textChunk as { value?: string }).value ?? "" + : ""; + if (!chunk) continue; + process.stdout.write(chunk); + final += chunk; + } + + if (!final.endsWith("\n")) { + process.stdout.write("\n"); + } + + return final.trim(); +} + export async function startCopilot(): Promise { if (!process.env.OPENAI_API_KEY) { console.error("OPENAI_API_KEY is not set. Please export it to use chat."); @@ -85,16 +154,38 @@ export async function startCopilot(): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log("Rowboat Copilot (type 'exit' to quit)"); + const debugMode = process.argv.includes("--debug") || process.env.COPILOT_DEBUG === "1"; + const conversationHistory: ConversationMessage[] = []; + const ask = () => rl.question("> ", async (line) => { if (!line || line.trim().toLowerCase() === "exit") { rl.close(); return; } try { - const cmd = await interpret(line); - console.log("\n=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); - const result = await execute(cmd); - console.log("\n=== Result ===\n" + JSON.stringify(result, null, 2) + "\n"); + const trimmed = line.trim(); + const cmd = await interpret(trimmed, conversationHistory); + let outcome: CommandOutcome; + try { + outcome = await executeCommand(cmd); + } finally { + // no-op + } + + const historyWithLatestUser: ConversationMessage[] = [ + ...conversationHistory, + { role: "user", content: trimmed }, + ]; + const assistantReply = await renderAssistantResponse(trimmed, cmd, outcome, historyWithLatestUser); + console.log(""); + + if (debugMode) { + console.log("=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); + console.log("\n=== Outcome ===\n" + JSON.stringify(outcome, null, 2) + "\n"); + } + + conversationHistory.push({ role: "user", content: trimmed }); + conversationHistory.push({ role: "assistant", content: assistantReply }); } catch (err) { console.error("Error:", (err as Error).message); } diff --git a/apps/cli/src/application/assistant/commands.ts b/apps/cli/src/application/assistant/commands.ts new file mode 100644 index 00000000..0e8d97c8 --- /dev/null +++ b/apps/cli/src/application/assistant/commands.ts @@ -0,0 +1,501 @@ +import { z } from "zod"; +import { + listWorkflows, + getWorkflow, + upsertWorkflow, + deleteWorkflow, +} from "./workflows/service.js"; +import { + listAgents, + getAgent, + upsertAgent, + deleteAgent, +} from "./agents/service.js"; +import { + readMcpConfig, + writeMcpConfig, +} from "./mcp/service.js"; +import { Agent } from "../entities/agent.js"; +import { Workflow } from "../entities/workflow.js"; + +export const ChatCommand = z.object({ + action: z.enum([ + "help", + "general_chat", + "list_workflows", + "get_workflow", + "describe_workflows", + "create_workflow", + "update_workflow", + "delete_workflow", + "list_agents", + "get_agent", + "create_agent", + "update_agent", + "delete_agent", + "list_mcp_servers", + "add_mcp_server", + "remove_mcp_server", + "run_workflow", + "unknown", + ]), + id: z.string().optional(), + query: z.string().optional(), + updates: Workflow.partial().optional(), + server: z + .object({ + name: z.string(), + url: z.string(), + }) + .optional(), + name: z.string().optional(), + clarification: z.string().optional(), + ids: z.array(z.string()).optional(), + scope: z.enum(["all"]).optional(), +}); + +export type ChatCommandT = z.infer; + +export type CommandStatus = "ok" | "error"; + +export interface CommandOutcome { + status: CommandStatus; + headline: string; + details?: string; + list?: string[]; + data?: unknown; +} + +function asCommandOutcome( + outcome: Omit & { status?: CommandStatus } +): CommandOutcome { + return { + status: outcome.status ?? "ok", + headline: outcome.headline, + details: outcome.details, + list: outcome.list, + data: outcome.data, + }; +} + +function normalizeKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const matrix: number[][] = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) + ); + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } + + return matrix[a.length][b.length]; +} + +function resolveWorkflowId( + input: string, + existing: string[] +): { id?: string; suggestion?: string } { + const exact = existing.find((candidate) => candidate === input); + if (exact) return { id: exact }; + + const normalizedInput = normalizeKey(input); + const normalizedMap = new Map(); + for (const candidate of existing) { + const key = normalizeKey(candidate); + if (!normalizedMap.has(key)) normalizedMap.set(key, candidate); + } + const normalizedMatch = normalizedMap.get(normalizedInput); + if (normalizedMatch) return { id: normalizedMatch }; + + const ranked = existing + .map((candidate) => ({ + id: candidate, + distance: levenshtein(normalizeKey(candidate), normalizedInput), + })) + .sort((a, b) => a.distance - b.distance); + + const best = ranked[0]; + if (best && best.distance <= 2) { + return { id: best.id }; + } + + return { suggestion: best?.id }; +} + +export async function executeCommand(cmd: ChatCommandT): Promise { + switch (cmd.action) { + case "help": + return asCommandOutcome({ + headline: "Try asking for workflows, agents, or MCP servers.", + list: [ + "list workflows", + "show workflow example_workflow", + "show all workflows in detail", + "create workflow demo that calls function get_date", + "list agents", + "add mcp server staging at http://localhost:8800", + ], + }); + case "list_workflows": { + const items = listWorkflows(); + return asCommandOutcome({ + headline: + items.length === 0 + ? "No workflows saved yet." + : `Found ${items.length} workflow${items.length === 1 ? "" : "s"}.`, + list: items, + data: { items }, + }); + } + case "get_workflow": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Workflow id required.", + details: "Provide the workflow name you want to inspect.", + }); + } + const allWorkflows = listWorkflows(); + const { id: resolvedId, suggestion } = resolveWorkflowId(cmd.id, allWorkflows); + if (!resolvedId) { + return asCommandOutcome({ + status: "error", + headline: `Workflow "${cmd.id}" was not found.`, + details: suggestion ? `Did you mean "${suggestion}"?` : undefined, + }); + } + const workflow = getWorkflow(resolvedId); + if (!workflow) { + return asCommandOutcome({ + status: "error", + headline: `Workflow "${resolvedId}" could not be loaded.`, + }); + } + return asCommandOutcome({ + headline: `Loaded workflow "${resolvedId}".`, + details: workflow.description || "No description set.", + data: workflow, + list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), + }); + } + case "describe_workflows": { + const allWorkflows = listWorkflows(); + const explicitIds = cmd.ids?.map((value) => value.trim()).filter((value) => value.length > 0) ?? []; + const targetIds = + explicitIds.length > 0 ? Array.from(new Set(explicitIds)) : cmd.scope === "all" ? [...allWorkflows] : []; + + if (targetIds.length === 0) { + return asCommandOutcome({ + status: "error", + headline: "No workflows specified.", + details: + explicitIds.length === 0 && cmd.scope !== "all" + ? "Provide workflow ids or set scope to \"all\"." + : "No workflows found to describe.", + }); + } + + const described: Array<{ id: string; workflow: z.infer }> = []; + const missing: string[] = []; + const suggestions: string[] = []; + const seen = new Set(); + + if (explicitIds.length === 0 && cmd.scope === "all") { + for (const id of allWorkflows) { + const workflow = getWorkflow(id); + if (workflow && !seen.has(id)) { + seen.add(id); + described.push({ id, workflow }); + } + } + } else { + for (const requestedId of targetIds) { + const { id: resolvedId, suggestion } = resolveWorkflowId(requestedId, allWorkflows); + if (!resolvedId) { + missing.push(requestedId); + if (suggestion) suggestions.push(`${requestedId} → ${suggestion}`); + continue; + } + if (seen.has(resolvedId)) continue; + seen.add(resolvedId); + const workflow = getWorkflow(resolvedId); + if (workflow) { + described.push({ id: resolvedId, workflow }); + } else { + missing.push(requestedId); + } + } + } + + if (described.length === 0) { + return asCommandOutcome({ + status: "error", + headline: "No workflows found.", + details: `Checked: ${targetIds.join(", ")}`, + }); + } + + const list = described.map(({ workflow }) => { + const description = workflow.description ? workflow.description : "No description set."; + const steps = workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`).join("; "); + return `${workflow.name}: ${description} Steps: ${steps || "None"}.`; + }); + + const details = + missing.length > 0 + ? `Missing workflows: ${missing.join(", ")}.${suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : ""}` + : suggestions.length > 0 + ? `Closest matches: ${suggestions.join(", ")}.` + : undefined; + + return asCommandOutcome({ + headline: `Showing ${described.length} workflow${described.length === 1 ? "" : "s"}.`, + details, + list, + data: { + workflows: described.map(({ workflow }) => workflow), + missing, + }, + }); + } + case "general_chat": + if (!cmd.query) { + return asCommandOutcome({ + status: "error", + headline: "Need the question to answer.", + details: "Repeat your request so I can help.", + }); + } + return asCommandOutcome({ + headline: "General assistance requested.", + details: cmd.query, + data: { query: cmd.query }, + }); + case "create_workflow": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Workflow id required.", + details: "Name the workflow you want to create.", + }); + } + const created = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + return asCommandOutcome({ + headline: `Workflow "${cmd.id}" saved.`, + data: created, + }); + } + case "update_workflow": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Workflow id required.", + details: "Name the workflow you want to update.", + }); + } + const updated = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); + return asCommandOutcome({ + headline: `Workflow "${cmd.id}" updated.`, + data: updated, + }); + } + case "delete_workflow": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Workflow id required.", + details: "Name the workflow you want to delete.", + }); + } + const deleted = deleteWorkflow(cmd.id); + return asCommandOutcome({ + headline: deleted + ? `Workflow "${cmd.id}" deleted.` + : `Workflow "${cmd.id}" did not exist.`, + data: { deleted }, + }); + } + case "list_agents": { + const items = listAgents(); + return asCommandOutcome({ + headline: + items.length === 0 + ? "No agents saved yet." + : `Found ${items.length} agent${items.length === 1 ? "" : "s"}.`, + list: items, + data: { items }, + }); + } + case "get_agent": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Agent id required.", + details: "Provide the agent name you want to inspect.", + }); + } + const agent = getAgent(cmd.id); + if (!agent) { + return asCommandOutcome({ + status: "error", + headline: `Agent "${cmd.id}" was not found.`, + }); + } + return asCommandOutcome({ + headline: `Loaded agent "${cmd.id}".`, + details: agent.description || "No description set.", + data: agent, + }); + } + case "create_agent": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Agent id required.", + details: "Name the agent you want to create.", + }); + } + const created = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); + return asCommandOutcome({ + headline: `Agent "${cmd.id}" saved.`, + data: created, + }); + } + case "update_agent": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Agent id required.", + details: "Name the agent you want to update.", + }); + } + const updated = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); + return asCommandOutcome({ + headline: `Agent "${cmd.id}" updated.`, + data: updated, + }); + } + case "delete_agent": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Agent id required.", + details: "Name the agent you want to delete.", + }); + } + const deleted = deleteAgent(cmd.id); + return asCommandOutcome({ + headline: deleted + ? `Agent "${cmd.id}" deleted.` + : `Agent "${cmd.id}" did not exist.`, + data: { deleted }, + }); + } + case "list_mcp_servers": { + const config = readMcpConfig(); + const servers = config.mcpServers; + return asCommandOutcome({ + headline: + servers.length === 0 + ? "No MCP servers configured." + : `Found ${servers.length} MCP server${servers.length === 1 ? "" : "s"}.`, + list: servers.map((server) => `${server.name} → ${server.url}`), + data: servers, + }); + } + case "add_mcp_server": { + const serverConfig = cmd.server; + if (!serverConfig) { + return asCommandOutcome({ + status: "error", + headline: "Server details required.", + details: "Provide a name and url for the MCP server.", + }); + } + const config = readMcpConfig(); + const withoutExisting = config.mcpServers.filter( + (server) => server.name !== serverConfig.name + ); + const updated = { + mcpServers: [...withoutExisting, { ...serverConfig }], + }; + writeMcpConfig(updated); + return asCommandOutcome({ + headline: `MCP server "${serverConfig.name}" saved.`, + data: updated.mcpServers, + }); + } + case "remove_mcp_server": { + const name = cmd.name; + if (!name) { + return asCommandOutcome({ + status: "error", + headline: "Server name required.", + details: "Tell me which MCP server to remove.", + }); + } + const config = readMcpConfig(); + const remaining = config.mcpServers.filter( + (server) => server.name !== name + ); + const removed = remaining.length !== config.mcpServers.length; + writeMcpConfig({ mcpServers: remaining }); + return asCommandOutcome({ + headline: removed + ? `MCP server "${name}" removed.` + : `MCP server "${name}" was not registered.`, + data: remaining, + }); + } + case "run_workflow": { + if (!cmd.id) { + return asCommandOutcome({ + status: "error", + headline: "Workflow id required.", + details: "Name the workflow you want to run.", + }); + } + const workflow = getWorkflow(cmd.id); + if (!workflow) { + return asCommandOutcome({ + status: "error", + headline: `Workflow "${cmd.id}" was not found.`, + }); + } + if (workflow.steps.length === 0) { + return asCommandOutcome({ + headline: `Workflow "${cmd.id}" is empty.`, + details: "Add function or agent steps before running.", + data: workflow, + }); + } + return asCommandOutcome({ + headline: `Workflow "${cmd.id}" is ready.`, + details: + "Running from the copilot will be available once the runtime bridge is connected.", + list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), + data: workflow, + }); + } + case "unknown": + return asCommandOutcome({ + status: "error", + headline: "I need more detail before taking action.", + details: cmd.clarification ?? "Try rephrasing or be more specific about the workflow, agent, or MCP server.", + }); + } +} From 776be21fbd012e98c8b57a02670b44d3884fd2ff Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:28:38 +0530 Subject: [PATCH 05/38] set up npx app --- apps/cli/bin/app.js | 4 ++ apps/cli/package.json | 15 ++++-- .../src/application/assistant/mcp/service.ts | 6 +-- .../application/assistant/services/storage.ts | 52 +++++-------------- apps/cli/src/application/config/config.ts | 9 ++-- apps/cli/src/x.ts | 13 +++-- 6 files changed, 40 insertions(+), 59 deletions(-) create mode 100755 apps/cli/bin/app.js diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js new file mode 100755 index 00000000..af06a71f --- /dev/null +++ b/apps/cli/bin/app.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node + +import { start } from '../dist/x.js'; +start(); \ No newline at end of file diff --git a/apps/cli/package.json b/apps/cli/package.json index 8a16c38f..c8b1b154 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { - "name": "cli", - "version": "1.0.0", + "name": "@rowboatlabs/rowboatx", + "version": "0.2.0", "main": "index.js", "type": "module", "scripts": { @@ -8,9 +8,16 @@ "build": "rm -rf dist && tsc", "copilot": "npm run build && node dist/x.js" }, + "files": [ + "dist", + "bin" + ], + "bin": { + "rowboatx": "bin/app.js" + }, "keywords": [], - "author": "", - "license": "ISC", + "author": "Rowboat Labs", + "license": "MIT", "description": "", "devDependencies": { "@types/node": "^24.9.1", diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts index c9d6da44..f5f27597 100644 --- a/apps/cli/src/application/assistant/mcp/service.ts +++ b/apps/cli/src/application/assistant/mcp/service.ts @@ -2,12 +2,10 @@ import fs from "fs"; import path from "path"; import { z } from "zod"; import { McpServerConfig } from "../../entities/mcp.js"; -import { ensureBaseDirs, getStoragePaths } from "../services/storage.js"; +import { WorkDir } from "../../config/config.js"; export function mcpConfigPath(): string { - const base = getStoragePaths(); - ensureBaseDirs(base); - return path.join(base.workDir, "mcp", "servers.json"); + return path.join(WorkDir, "mcp", "servers.json"); } export function readMcpConfig(): z.infer { diff --git a/apps/cli/src/application/assistant/services/storage.ts b/apps/cli/src/application/assistant/services/storage.ts index ccf14c44..4ec0f2c9 100644 --- a/apps/cli/src/application/assistant/services/storage.ts +++ b/apps/cli/src/application/assistant/services/storage.ts @@ -1,48 +1,22 @@ import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; +import { WorkDir } from "../../config/config.js"; export type DirKind = "workflows" | "agents" | "mcp"; -export interface StoragePaths { - appRoot: string; - workDir: string; // .rowboat -} - -const defaultPaths: StoragePaths = (() => { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const appRoot = path.resolve(__dirname, "../../../../"); - const workDir = path.join(appRoot, ".rowboat"); - return { appRoot, workDir }; -})(); - -export function getStoragePaths(): StoragePaths { - return defaultPaths; -} - -export function ensureBaseDirs(base: StoragePaths = defaultPaths) { - const ensure = (p: string) => { - if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); - }; - ensure(base.workDir); - ensure(path.join(base.workDir, "workflows")); - ensure(path.join(base.workDir, "agents")); - ensure(path.join(base.workDir, "mcp")); -} - -export function dirFor(kind: DirKind, base: StoragePaths = defaultPaths): string { +export function dirFor(kind: DirKind): string { switch (kind) { case "workflows": - return path.join(base.workDir, "workflows"); + return path.join(WorkDir, "workflows"); case "agents": - return path.join(base.workDir, "agents"); + return path.join(WorkDir, "agents"); case "mcp": - return path.join(base.workDir, "mcp"); + return path.join(WorkDir, "mcp"); } } -export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): string[] { - const d = dirFor(kind, base); +export function listJson(kind: DirKind): string[] { + const d = dirFor(kind); if (!fs.existsSync(d)) return []; return fs .readdirSync(d) @@ -50,20 +24,20 @@ export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): stri .map((f) => f.replace(/\.json$/, "")); } -export function readJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): T | undefined { - const p = path.join(dirFor(kind, base), `${id}.json`); +export function readJson(kind: DirKind, id: string): T | undefined { + const p = path.join(dirFor(kind), `${id}.json`); if (!fs.existsSync(p)) return undefined; const raw = fs.readFileSync(p, "utf8"); return JSON.parse(raw) as T; } -export function writeJson(kind: DirKind, id: string, value: unknown, base: StoragePaths = defaultPaths): void { - const p = path.join(dirFor(kind, base), `${id}.json`); +export function writeJson(kind: DirKind, id: string, value: unknown): void { + const p = path.join(dirFor(kind), `${id}.json`); fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8"); } -export function deleteJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): boolean { - const p = path.join(dirFor(kind, base), `${id}.json`); +export function deleteJson(kind: DirKind, id: string): boolean { + const p = path.join(dirFor(kind), `${id}.json`); if (!fs.existsSync(p)) return false; fs.rmSync(p); return true; diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 2692b300..9ab37014 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -1,13 +1,11 @@ import path from "path"; import fs from "fs"; -import { fileURLToPath } from "url"; import { McpServerConfig } from "../entities/mcp.js"; import { z } from "zod"; +import { homedir } from "os"; // Resolve app root relative to compiled file location (dist/...) -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const AppRoot = path.resolve(__dirname, "../../.."); -export const WorkDir = path.join(AppRoot, ".rowboat"); +export const WorkDir = path.join(homedir(), ".rowboat"); function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; @@ -17,8 +15,9 @@ function ensureDirs() { ensure(path.join(WorkDir, "mcp")); } +ensureDirs(); + function loadMcpServerConfig(): z.infer { - ensureDirs(); const configPath = path.join(WorkDir, "mcp", "servers.json"); if (!fs.existsSync(configPath)) return { mcpServers: [] }; const config = fs.readFileSync(configPath, "utf8"); diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts index cf823710..9dbd8edb 100644 --- a/apps/cli/src/x.ts +++ b/apps/cli/src/x.ts @@ -1,9 +1,8 @@ -import { ensureBaseDirs } from "./application/assistant/services/storage.js"; import { startCopilot } from "./application/assistant/chat.js"; -ensureBaseDirs(); - -startCopilot().catch((err) => { - console.error("Failed to run copilot:", err); - process.exitCode = 1; -}); +export const start = () => { + startCopilot().catch((err) => { + console.error("Failed to run copilot:", err); + process.exitCode = 1; + }); +} From 7758139893f184da60e34f40e4ade554dec92fd4 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:29:12 +0530 Subject: [PATCH 06/38] upgrade to 0.3.0 --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index c8b1b154..50ceceb7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rowboatlabs/rowboatx", - "version": "0.2.0", + "version": "0.3.0", "main": "index.js", "type": "module", "scripts": { From c004bc5eb65d6706d20f40c1f8f6f7c083995337 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:42:10 +0530 Subject: [PATCH 07/38] set up basic workflow execution --- apps/cli/.rowboat/.gitignore | 2 - apps/cli/package-lock.json | 23 +- apps/cli/package.json | 1 + apps/cli/src/app.ts | 184 +-------------- apps/cli/src/application/assistant/chat.ts | 2 +- .../cli/src/application/assistant/commands.ts | 36 +-- .../src/application/assistant/mcp/service.ts | 2 +- apps/cli/src/application/config/config.ts | 32 ++- apps/cli/src/application/entities/agent.ts | 27 +++ .../application/entities/llm-step-event.ts | 56 +++++ apps/cli/src/application/entities/mcp.ts | 16 +- apps/cli/src/application/entities/message.ts | 2 +- .../src/application/entities/stream-event.ts | 56 ----- .../application/entities/workflow-event.ts | 69 ++++++ apps/cli/src/application/entities/workflow.ts | 2 +- .../cli/src/application/functions/get_date.ts | 12 +- .../src/application/{nodes => lib}/agent.ts | 114 ++++++++-- apps/cli/src/application/lib/exec-tool.ts | 102 +++++++++ apps/cli/src/application/lib/exec-workflow.ts | 215 ++++++++++++++++++ apps/cli/src/application/lib/step.ts | 13 ++ .../src/application/lib/stream-renderer.ts | 102 ++++++++- apps/cli/src/application/lib/utils.ts | 10 + apps/cli/src/application/nodes/node.ts | 10 - .../cli/src/application/registry/functions.ts | 4 +- 24 files changed, 794 insertions(+), 298 deletions(-) delete mode 100644 apps/cli/.rowboat/.gitignore create mode 100644 apps/cli/src/application/entities/llm-step-event.ts delete mode 100644 apps/cli/src/application/entities/stream-event.ts create mode 100644 apps/cli/src/application/entities/workflow-event.ts rename apps/cli/src/application/{nodes => lib}/agent.ts (52%) create mode 100644 apps/cli/src/application/lib/exec-tool.ts create mode 100644 apps/cli/src/application/lib/exec-workflow.ts create mode 100644 apps/cli/src/application/lib/step.ts create mode 100644 apps/cli/src/application/lib/utils.ts delete mode 100644 apps/cli/src/application/nodes/node.ts diff --git a/apps/cli/.rowboat/.gitignore b/apps/cli/.rowboat/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/apps/cli/.rowboat/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/apps/cli/package-lock.json b/apps/cli/package-lock.json index 23f6872e..28c676f5 100644 --- a/apps/cli/package-lock.json +++ b/apps/cli/package-lock.json @@ -1,21 +1,25 @@ { - "name": "cli", - "version": "1.0.0", + "name": "@rowboatlabs/rowboatx", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cli", - "version": "1.0.0", - "license": "ISC", + "name": "@rowboatlabs/rowboatx", + "version": "0.3.0", + "license": "MIT", "dependencies": { "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", "@modelcontextprotocol/sdk": "^1.20.2", "ai": "^5.0.78", + "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", "zod": "^4.1.12" }, + "bin": { + "rowboatx": "bin/app.js" + }, "devDependencies": { "@types/node": "^24.9.1", "ts-node": "^10.9.2", @@ -859,6 +863,15 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-to-zod": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", + "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/apps/cli/package.json b/apps/cli/package.json index 50ceceb7..dfd4c3bb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,7 @@ "@ai-sdk/openai": "^2.0.53", "@modelcontextprotocol/sdk": "^1.20.2", "ai": "^5.0.78", + "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", "zod": "^4.1.12" } diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 03f46280..4198972a 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -1,185 +1,15 @@ -import fs from "fs"; -import path from "path"; -import { WorkDir, McpServers } from "./application/config/config.js"; -import { Workflow } from "./application/entities/workflow.js"; -import { FunctionsRegistry } from "./application/registry/functions.js"; -import { AgentNode } from "./application/nodes/agent.js"; -import { MessageList, AssistantContentPart } from "./application/entities/message.js"; -import { z } from "zod"; -import { getMcpClient } from "./application/lib/mcp.js"; -import { streamText } from "ai"; -import { openai } from "@ai-sdk/openai"; -import { google } from "@ai-sdk/google"; +import { executeWorkflow } from "./application/lib/exec-workflow.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; -import { StreamEvent } from "./application/entities/stream-event.js"; -import { AssistantMessage, Message } from "./application/entities/message.js"; -import { randomId } from "./application/lib/random-id.js"; -class RunLogger { - private logFile: string; - private fileHandle: fs.WriteStream; - - ensureRunsDir(workflowId: string) { - const runsDir = path.join(WorkDir, "runs", workflowId); - if (!fs.existsSync(runsDir)) { - fs.mkdirSync(runsDir, { recursive: true }); - } - } - - constructor(workflowId: string, runId: string) { - this.ensureRunsDir(workflowId); - this.logFile = path.join(WorkDir, "runs", `${workflowId}`, `${runId}.jsonl`); - this.fileHandle = fs.createWriteStream(this.logFile, { - flags: "a", - encoding: "utf8", - }); - } - - log(message: z.infer) { - this.fileHandle.write(JSON.stringify(message) + "\n"); - } - - close() { - this.fileHandle.close(); - } -} - -class StreamStepMessageBuilder { - private parts: z.infer[] = []; - private textBuffer: string = ""; - private reasoningBuffer: string = ""; - - flushBuffers() { - if (this.reasoningBuffer) { - this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); - this.reasoningBuffer = ""; - } - if (this.textBuffer) { - this.parts.push({ type: "text", text: this.textBuffer }); - this.textBuffer = ""; - } - } - - ingest(event: z.infer) { - switch (event.type) { - case "reasoning-start": - case "reasoning-end": - case "text-start": - case "text-end": - this.flushBuffers(); - break; - case "reasoning-delta": - this.reasoningBuffer += event.delta; - break; - case "text-delta": - this.textBuffer += event.delta; - break; - case "tool-call": - this.parts.push({ - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - arguments: event.input, - }); - break; - } - } - - get(): z.infer { - this.flushBuffers(); - return { - role: "assistant", - content: this.parts, - }; - } -} - -function loadWorkflow(id: string) { - const workflowPath = path.join(WorkDir, "workflows", `${id}.json`); - const workflow = fs.readFileSync(workflowPath, "utf8"); - return Workflow.parse(JSON.parse(workflow)); -} - -function loadFunction(id: string) { - const func = FunctionsRegistry[id]; - if (!func) { - throw new Error(`Function ${id} not found`); - } - return func; -} - -async function callMcpTool(serverName: string, toolName: string, args: Record) { - const server = McpServers.find(server => server.name === serverName); - if (!server) { - throw new Error(`MCP server ${serverName} not found`); - } - const client = await getMcpClient(server.url, server.name); - const response = await client.callTool({ name: toolName, arguments: args }); - return response; -} - -async function executeWorkflow(id: string) { - const workflow = loadWorkflow(id); - // console.log("got", JSON.stringify(workflow)); - - const runId = await randomId(); - const logger = new RunLogger(id, runId); - - const input: z.infer = [{ - role: "user", - content: "What is the current date?" - }]; - const msgs: z.infer = [...input]; - - try { - const renderer = new StreamRenderer(); - - for await (const step of workflow.steps) { - const node = step.type === "agent" ? new AgentNode(step.id) : loadFunction(step.id); - const messageBuilder = new StreamStepMessageBuilder(); - for await (const event of node.execute(msgs)) { - // console.log(" - event", JSON.stringify(event)); - messageBuilder.ingest(event); - renderer.render(event); - } - const msg = messageBuilder.get(); - logger.log(msg); - msgs.push(msg); - } - } finally { - logger.close(); - } - - console.log('\n\n', JSON.stringify(msgs, null, 2)); -} - -async function streamEventTest() { - const { fullStream } = streamText({ - model: openai("gpt-5"), - system: "You are a helpful assistant that reasons about the world. Provide a reason for invoking any tools", - messages: [{ role: "user", content: "what is the current date and time?" }], - tools: { - getDate: { - description: "Get the current date", - inputSchema: z.object({ - format: z.enum(["long", "short"]).default("long"), - }), - }, - getTime: { - description: "Get the current time", - inputSchema: z.object({ - format: z.enum(["long", "short"]).default("long"), - }), - }, - }, - }); +async function runWorkflow(id: string, userInput: string) { const renderer = new StreamRenderer(); - for await (const event of fullStream) { - renderer.render(event as any); + for await (const event of executeWorkflow(id, userInput)) { + renderer.render(event); } } -// streamEventTest(); +const workflowId = process.argv[2] ?? "example_workflow"; +const userInputMsg = process.argv[3] ?? ""; -executeWorkflow("example_workflow"); \ No newline at end of file +runWorkflow(workflowId, userInputMsg); \ No newline at end of file diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index e96a23ee..bd3a2d45 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -152,7 +152,7 @@ export async function startCopilot(): Promise { } const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - console.log("Rowboat Copilot (type 'exit' to quit)"); + console.log("XRowboat Copilot (type 'exit' to quit)"); const debugMode = process.argv.includes("--debug") || process.env.COPILOT_DEBUG === "1"; const conversationHistory: ConversationMessage[] = []; diff --git a/apps/cli/src/application/assistant/commands.ts b/apps/cli/src/application/assistant/commands.ts index 0e8d97c8..9ccf86ba 100644 --- a/apps/cli/src/application/assistant/commands.ts +++ b/apps/cli/src/application/assistant/commands.ts @@ -407,13 +407,23 @@ export async function executeCommand(cmd: ChatCommandT): Promise } case "list_mcp_servers": { const config = readMcpConfig(); - const servers = config.mcpServers; + const servers = Object.keys(config.mcpServers); + + const list: string[] = []; + for (const server of servers) { + if ('url' in config.mcpServers[server]) { + list.push(`${server} → ${config.mcpServers[server].url}`); + } else { + list.push(`${server} → ${config.mcpServers[server].command}`); + } + } + return asCommandOutcome({ headline: servers.length === 0 ? "No MCP servers configured." : `Found ${servers.length} MCP server${servers.length === 1 ? "" : "s"}.`, - list: servers.map((server) => `${server.name} → ${server.url}`), + list, data: servers, }); } @@ -427,16 +437,14 @@ export async function executeCommand(cmd: ChatCommandT): Promise }); } const config = readMcpConfig(); - const withoutExisting = config.mcpServers.filter( - (server) => server.name !== serverConfig.name - ); - const updated = { - mcpServers: [...withoutExisting, { ...serverConfig }], + config.mcpServers[serverConfig.name] = { + url: serverConfig.url, + headers: {}, }; - writeMcpConfig(updated); + writeMcpConfig(config); return asCommandOutcome({ headline: `MCP server "${serverConfig.name}" saved.`, - data: updated.mcpServers, + data: config.mcpServers, }); } case "remove_mcp_server": { @@ -449,16 +457,14 @@ export async function executeCommand(cmd: ChatCommandT): Promise }); } const config = readMcpConfig(); - const remaining = config.mcpServers.filter( - (server) => server.name !== name - ); - const removed = remaining.length !== config.mcpServers.length; - writeMcpConfig({ mcpServers: remaining }); + delete config.mcpServers[name]; + writeMcpConfig(config); + const removed = name in config.mcpServers; return asCommandOutcome({ headline: removed ? `MCP server "${name}" removed.` : `MCP server "${name}" was not registered.`, - data: remaining, + data: config.mcpServers, }); } case "run_workflow": { diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts index f5f27597..44014ec7 100644 --- a/apps/cli/src/application/assistant/mcp/service.ts +++ b/apps/cli/src/application/assistant/mcp/service.ts @@ -10,7 +10,7 @@ export function mcpConfigPath(): string { export function readMcpConfig(): z.infer { const p = mcpConfigPath(); - if (!fs.existsSync(p)) return { mcpServers: [] }; + if (!fs.existsSync(p)) return { mcpServers: {} }; const raw = fs.readFileSync(p, "utf8"); return McpServerConfig.parse(JSON.parse(raw)); } diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 9ab37014..8ee404b9 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -7,19 +7,45 @@ import { homedir } from "os"; // Resolve app root relative to compiled file location (dist/...) export const WorkDir = path.join(homedir(), ".rowboat"); +const baseMcpConfig = { + mcpServers: { + firecrawl: { + command: "npx", + args: ["-y", "supergateway", "--stdio", "npx -y firecrawl-mcp"], + env: { + FIRECRAWL_API_KEY: "fc-aaacee4bdd164100a4d83af85bef6fdc", + }, + }, + test: { + url: "http://localhost:3000", + headers: { + "Authorization": "Bearer test", + }, + }, + } +} + +function ensureMcpConfig() { + const configPath = path.join(WorkDir, "config", "mcp.json"); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify(baseMcpConfig, null, 2)); + } +} + function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; ensure(WorkDir); ensure(path.join(WorkDir, "workflows")); ensure(path.join(WorkDir, "agents")); - ensure(path.join(WorkDir, "mcp")); + ensure(path.join(WorkDir, "config")); + ensureMcpConfig(); } ensureDirs(); function loadMcpServerConfig(): z.infer { - const configPath = path.join(WorkDir, "mcp", "servers.json"); - if (!fs.existsSync(configPath)) return { mcpServers: [] }; + const configPath = path.join(WorkDir, "config", "mcp.json"); + if (!fs.existsSync(configPath)) return { mcpServers: {} }; const config = fs.readFileSync(configPath, "utf8"); return McpServerConfig.parse(JSON.parse(config)); } diff --git a/apps/cli/src/application/entities/agent.ts b/apps/cli/src/application/entities/agent.ts index a8d493e9..adea0505 100644 --- a/apps/cli/src/application/entities/agent.ts +++ b/apps/cli/src/application/entities/agent.ts @@ -1,7 +1,34 @@ import { z } from "zod"; + +export const BaseAgentTool = z.object({ + name: z.string(), +}); + +export const BuiltinAgentTool = BaseAgentTool.extend({ + type: z.literal("builtin"), +}); + +export const McpAgentTool = BaseAgentTool.extend({ + type: z.literal("mcp"), + description: z.string(), + inputSchema: z.any(), + mcpServerName: z.string(), +}); + +export const WorkflowAgentTool = BaseAgentTool.extend({ + type: z.literal("workflow"), +}); + +export const AgentTool = z.discriminatedUnion("type", [ + BuiltinAgentTool, + McpAgentTool, + WorkflowAgentTool, +]); + export const Agent = z.object({ name: z.string(), model: z.string(), description: z.string(), instructions: z.string(), + tools: z.record(z.string(), AgentTool).optional(), }); diff --git a/apps/cli/src/application/entities/llm-step-event.ts b/apps/cli/src/application/entities/llm-step-event.ts new file mode 100644 index 00000000..65e7d8a5 --- /dev/null +++ b/apps/cli/src/application/entities/llm-step-event.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export const LlmStepStreamReasoningStartEvent = z.object({ + type: z.literal("reasoning-start"), +}); + +export const LlmStepStreamReasoningDeltaEvent = z.object({ + type: z.literal("reasoning-delta"), + delta: z.string(), +}); + +export const LlmStepStreamReasoningEndEvent = z.object({ + type: z.literal("reasoning-end"), +}); + +export const LlmStepStreamTextStartEvent = z.object({ + type: z.literal("text-start"), +}); + +export const LlmStepStreamTextDeltaEvent = z.object({ + type: z.literal("text-delta"), + delta: z.string(), +}); + +export const LlmStepStreamTextEndEvent = z.object({ + type: z.literal("text-end"), +}); + +export const LlmStepStreamToolCallEvent = z.object({ + type: z.literal("tool-call"), + toolCallId: z.string(), + toolName: z.string(), + input: z.any(), +}); + +export const LlmStepStreamUsageEvent = z.object({ + type: z.literal("usage"), + usage: z.object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional(), + reasoningTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + }), +}); + +export const LlmStepStreamEvent = z.union([ + LlmStepStreamReasoningStartEvent, + LlmStepStreamReasoningDeltaEvent, + LlmStepStreamReasoningEndEvent, + LlmStepStreamTextStartEvent, + LlmStepStreamTextDeltaEvent, + LlmStepStreamTextEndEvent, + LlmStepStreamToolCallEvent, + LlmStepStreamUsageEvent, +]); \ No newline at end of file diff --git a/apps/cli/src/application/entities/mcp.ts b/apps/cli/src/application/entities/mcp.ts index 77aa63d3..4f6490cd 100644 --- a/apps/cli/src/application/entities/mcp.ts +++ b/apps/cli/src/application/entities/mcp.ts @@ -1,8 +1,16 @@ import z from "zod"; +const StdioMcpServerConfig = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +const HttpMcpServerConfig = z.object({ + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), +}); + export const McpServerConfig = z.object({ - mcpServers: z.array(z.object({ - name: z.string(), - url: z.string(), - })), + mcpServers: z.record(z.string(), z.union([StdioMcpServerConfig, HttpMcpServerConfig])), }); \ No newline at end of file diff --git a/apps/cli/src/application/entities/message.ts b/apps/cli/src/application/entities/message.ts index ef937e51..ce5d4b67 100644 --- a/apps/cli/src/application/entities/message.ts +++ b/apps/cli/src/application/entities/message.ts @@ -14,7 +14,7 @@ export const ToolCallPart = z.object({ type: z.literal("tool-call"), toolCallId: z.string(), toolName: z.string(), - arguments: z.string(), + arguments: z.any(), }); export const AssistantContentPart = z.union([ diff --git a/apps/cli/src/application/entities/stream-event.ts b/apps/cli/src/application/entities/stream-event.ts deleted file mode 100644 index 8898599f..00000000 --- a/apps/cli/src/application/entities/stream-event.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from "zod"; - -export const ReasoningStartEvent = z.object({ - type: z.literal("reasoning-start"), -}); - -export const ReasoningDeltaEvent = z.object({ - type: z.literal("reasoning-delta"), - delta: z.string(), -}); - -export const ReasoningEndEvent = z.object({ - type: z.literal("reasoning-end"), -}); - -export const TextStartEvent = z.object({ - type: z.literal("text-start"), -}); - -export const TextDeltaEvent = z.object({ - type: z.literal("text-delta"), - delta: z.string(), -}); - -export const TextEndEvent = z.object({ - type: z.literal("text-end"), -}); - -export const ToolCallEvent = z.object({ - type: z.literal("tool-call"), - toolCallId: z.string(), - toolName: z.string(), - input: z.any(), -}); - -export const UsageEvent = z.object({ - type: z.literal("usage"), - usage: z.object({ - inputTokens: z.number().optional(), - outputTokens: z.number().optional(), - totalTokens: z.number().optional(), - reasoningTokens: z.number().optional(), - cachedInputTokens: z.number().optional(), - }), -}); - -export const StreamEvent = z.union([ - ReasoningStartEvent, - ReasoningDeltaEvent, - ReasoningEndEvent, - TextStartEvent, - TextDeltaEvent, - TextEndEvent, - ToolCallEvent, - UsageEvent, -]); \ No newline at end of file diff --git a/apps/cli/src/application/entities/workflow-event.ts b/apps/cli/src/application/entities/workflow-event.ts new file mode 100644 index 00000000..4811db11 --- /dev/null +++ b/apps/cli/src/application/entities/workflow-event.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { LlmStepStreamEvent } from "./llm-step-event.js"; +import { Workflow } from "./workflow.js"; +import { Message } from "./message.js"; + +export const WorkflowStreamStartEvent = z.object({ + type: z.literal("workflow-start"), + workflowId: z.string(), + workflow: Workflow, + background: z.boolean(), +}); + +export const WorkflowStreamStepStartEvent = z.object({ + type: z.literal("workflow-step-start"), + stepId: z.string(), + stepType: z.enum(["agent", "function"]), +}); + +export const WorkflowStreamStepStreamEventEvent = z.object({ + type: z.literal("workflow-step-stream-event"), + stepId: z.string(), + event: LlmStepStreamEvent, +}); + +export const WorkflowStreamStepMessageEvent = z.object({ + type: z.literal("workflow-step-message"), + stepId: z.string(), + message: Message, +}); + +export const WorkflowStreamStepToolInvocationEvent = z.object({ + type: z.literal("workflow-step-tool-invocation"), + stepId: z.string(), + toolName: z.string(), + input: z.string(), +}); + +export const WorkflowStreamStepToolResultEvent = z.object({ + type: z.literal("workflow-step-tool-result"), + stepId: z.string(), + toolName: z.string(), + result: z.any(), +}); + +export const WorkflowStreamStepEndEvent = z.object({ + type: z.literal("workflow-step-end"), + stepId: z.string(), +}); + +export const WorkflowStreamEndEvent = z.object({ + type: z.literal("workflow-end"), +}); + +export const WorkflowStreamErrorEvent = z.object({ + type: z.literal("workflow-error"), + error: z.string(), +}); + +export const WorkflowStreamEvent = z.union([ + WorkflowStreamStartEvent, + WorkflowStreamStepStartEvent, + WorkflowStreamStepStreamEventEvent, + WorkflowStreamStepMessageEvent, + WorkflowStreamStepToolInvocationEvent, + WorkflowStreamStepToolResultEvent, + WorkflowStreamStepEndEvent, + WorkflowStreamEndEvent, + WorkflowStreamErrorEvent, +]); \ No newline at end of file diff --git a/apps/cli/src/application/entities/workflow.ts b/apps/cli/src/application/entities/workflow.ts index 9304371e..804dad3c 100644 --- a/apps/cli/src/application/entities/workflow.ts +++ b/apps/cli/src/application/entities/workflow.ts @@ -10,7 +10,7 @@ const FunctionStep = z.object({ id: z.string(), }); -const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]); +export const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]); export const Workflow = z.object({ name: z.string(), diff --git a/apps/cli/src/application/functions/get_date.ts b/apps/cli/src/application/functions/get_date.ts index 9f421086..e8561d80 100644 --- a/apps/cli/src/application/functions/get_date.ts +++ b/apps/cli/src/application/functions/get_date.ts @@ -1,7 +1,9 @@ -import { Node, NodeOutputT } from "../nodes/node.js"; +import { z } from "zod"; +import { Step, StepOutputT } from "../lib/step.js"; +import { AgentTool } from "../entities/agent.js"; -export class GetDate implements Node { - async* execute(): NodeOutputT { +export class GetDate implements Step { + async* execute(): StepOutputT { yield { type: "text-start", }; @@ -13,4 +15,8 @@ export class GetDate implements Node { type: "text-end", }; } + + tools(): Record> { + return {}; + } } \ No newline at end of file diff --git a/apps/cli/src/application/nodes/agent.ts b/apps/cli/src/application/lib/agent.ts similarity index 52% rename from apps/cli/src/application/nodes/agent.ts rename to apps/cli/src/application/lib/agent.ts index 4e0be41a..ef8321e5 100644 --- a/apps/cli/src/application/nodes/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -1,12 +1,58 @@ -import { Message } from "../entities/message.js"; +import { Message, MessageList } from "../entities/message.js"; import { z } from "zod"; -import { Node, NodeInputT, NodeOutputT } from "./node.js"; +import { Step, StepInputT, StepOutputT } from "./step.js"; import { openai } from "@ai-sdk/openai"; -import { generateText, ModelMessage, stepCountIs, streamText } from "ai"; -import { Agent } from "../entities/agent.js"; +import { google } from "@ai-sdk/google"; +import { generateText, ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai"; +import { Agent, AgentTool } from "../entities/agent.js"; import { WorkDir } from "../config/config.js"; import fs from "fs"; import path from "path"; +import { loadWorkflow } from "./utils.js"; + +const BashTool = tool({ + description: "Run a command in the shell", + inputSchema: z.object({ + command: z.string(), + }), +}); + +const AskHumanTool = tool({ + description: "Ask the human for input", + inputSchema: z.object({ + question: z.string(), + }), +}); + +function mapAgentTool(t: z.infer): Tool { + switch (t.type) { + case "mcp": + return tool({ + name: t.name, + description: t.description, + inputSchema: jsonSchema(t.inputSchema), + }); + case "workflow": + const workflow = loadWorkflow(t.name); + if (!workflow) { + throw new Error(`Workflow ${t.name} not found`); + } + return tool({ + name: t.name, + description: workflow.description, + inputSchema: z.object({ + message: z.string().describe("The message to send to the workflow"), + }), + }); + case "builtin": + switch (t.name) { + case "bash": + return BashTool; + default: + throw new Error(`Unknown builtin tool: ${t.name}`); + } + } +} function convertFromMessages(messages: z.infer[]): ModelMessage[] { const result: ModelMessage[] = []; @@ -51,34 +97,72 @@ function convertFromMessages(messages: z.infer[]): ModelMessage[ content: msg.content, }); break; + case "tool": + result.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: msg.toolCallId, + toolName: msg.toolName, + output: { + type: "text", + value: msg.content, + }, + }, + ], + }); + break; } } return result; } -export class AgentNode implements Node { +export class AgentNode implements Step { private id: string; + private background: boolean; + private agent: z.infer; - constructor(id: string) { + constructor(id: string, background: boolean) { this.id = id; - } - - private loadAgent(id: string): z.infer { + this.background = background; const agentPath = path.join(WorkDir, "agents", `${id}.json`); const agent = fs.readFileSync(agentPath, "utf8"); - return Agent.parse(JSON.parse(agent)); + this.agent = Agent.parse(JSON.parse(agent)); + } + + tools(): Record> { + return this.agent.tools ?? {}; } - async* execute(input: NodeInputT): NodeOutputT { - const agent = this.loadAgent(this.id); - const { fullStream } = await streamText({ - model: openai(agent.model), + async* execute(input: StepInputT): StepOutputT { + // console.log("\n\n\t>>>>\t\tinput", JSON.stringify(input)); + const tools: ToolSet = {}; + if (!this.background) { + tools["ask-human"] = AskHumanTool; + } + for (const [name, tool] of Object.entries(this.agent.tools ?? {})) { + try { + tools[name] = mapAgentTool(tool); + } catch (error) { + console.error(`Error mapping tool ${name}:`, error); + continue; + } + } + + // console.log("\n\n\t>>>>\t\ttools", JSON.stringify(tools, null, 2)); + + const { fullStream } = streamText({ + model: openai("gpt-4.1"), + // model: google("gemini-2.5-pro"), messages: convertFromMessages(input), - system: agent.instructions, + system: this.agent.instructions, stopWhen: stepCountIs(1), + tools, }); for await (const event of fullStream) { + // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); switch (event.type) { case "reasoning-start": yield { diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts new file mode 100644 index 00000000..e703fd41 --- /dev/null +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -0,0 +1,102 @@ +import { tool, Tool } from "ai"; +import { AgentTool } from "../entities/agent.js"; +import { z } from "zod"; +import { McpServers } from "../config/config.js"; +import { getMcpClient } from "./mcp.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { Client } from "@modelcontextprotocol/sdk/client"; +import { executeCommand } from "./command-executor.js"; +import { loadWorkflow } from "./utils.js"; +import { AssistantMessage } from "../entities/message.js"; +import { executeWorkflow } from "./exec-workflow.js"; + +async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { + // load mcp configuration from the tool + const mcpConfig = McpServers[agentTool.mcpServerName]; + if (!mcpConfig) { + throw new Error(`MCP server ${agentTool.mcpServerName} not found`); + } + + // create transport + let transport: Transport; + if ("command" in mcpConfig) { + transport = new StdioClientTransport({ + command: mcpConfig.command, + args: mcpConfig.args, + env: mcpConfig.env, + }); + } else { + // first try streamable http transport + try { + transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url)); + } catch (error) { + // if that fails, try sse transport + transport = new SSEClientTransport(new URL(mcpConfig.url)); + } + } + + if (!transport) { + throw new Error(`No transport found for ${agentTool.mcpServerName}`); + } + + // create client + const client = new Client({ + name: 'rowboatx', + version: '1.0.0', + }); + await client.connect(transport); + + // call tool + const result = await client.callTool({ name: agentTool.name, arguments: input }); + client.close(); + transport.close(); + return result; +} + +async function execBashTool(agentTool: z.infer, input: any): Promise { + const result = await executeCommand(input.command as string); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }; +} + +async function execWorkflowTool(agentTool: z.infer & { type: "workflow" }, input: any): Promise { + let lastMsg: z.infer | null = null; + for await (const event of executeWorkflow(agentTool.name, input.message)) { + if (event.type === "workflow-step-message" && event.message.role === "assistant") { + lastMsg = event.message; + } + if (event.type === "workflow-error") { + throw new Error(event.error); + } + } + + if (!lastMsg) { + throw new Error("No message received from workflow"); + } + if (typeof lastMsg.content === "string") { + return lastMsg.content; + } + return lastMsg.content.reduce((acc, part) => { + if (part.type === "text") { + acc += part.text; + } + return acc; + }, ""); +} + +export async function execTool(agentTool: z.infer, input: any): Promise { + switch (agentTool.type) { + case "mcp": + return execMcpTool(agentTool, input); + case "workflow": + return execWorkflowTool(agentTool, input); + case "builtin": + return execBashTool(agentTool, input); + } +} \ No newline at end of file diff --git a/apps/cli/src/application/lib/exec-workflow.ts b/apps/cli/src/application/lib/exec-workflow.ts new file mode 100644 index 00000000..39c620d1 --- /dev/null +++ b/apps/cli/src/application/lib/exec-workflow.ts @@ -0,0 +1,215 @@ +import { loadWorkflow } from "./utils.js"; +import { randomId } from "./random-id.js"; +import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage } from "../entities/message.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; +import { AgentNode } from "./agent.js"; +import { z } from "zod"; +import path from "path"; +import { WorkDir } from "../config/config.js"; +import fs from "fs"; +import { FunctionsRegistry } from "../registry/functions.js"; +import { WorkflowStreamEvent } from "../entities/workflow-event.js"; +import { execTool } from "./exec-tool.js"; + +class RunLogger { + private logFile: string; + private fileHandle: fs.WriteStream; + + ensureRunsDir(workflowId: string) { + const runsDir = path.join(WorkDir, "runs", workflowId); + if (!fs.existsSync(runsDir)) { + fs.mkdirSync(runsDir, { recursive: true }); + } + } + + constructor(workflowId: string, runId: string) { + this.ensureRunsDir(workflowId); + this.logFile = path.join(WorkDir, "runs", `${workflowId}`, `${runId}.jsonl`); + this.fileHandle = fs.createWriteStream(this.logFile, { + flags: "a", + encoding: "utf8", + }); + } + + log(message: z.infer) { + this.fileHandle.write(JSON.stringify(message) + "\n"); + } + + close() { + this.fileHandle.close(); + } +} + +class StreamStepMessageBuilder { + private parts: z.infer[] = []; + private textBuffer: string = ""; + private reasoningBuffer: string = ""; + + flushBuffers() { + if (this.reasoningBuffer) { + this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); + this.reasoningBuffer = ""; + } + if (this.textBuffer) { + this.parts.push({ type: "text", text: this.textBuffer }); + this.textBuffer = ""; + } + } + + ingest(event: z.infer) { + switch (event.type) { + case "reasoning-start": + case "reasoning-end": + case "text-start": + case "text-end": + this.flushBuffers(); + break; + case "reasoning-delta": + this.reasoningBuffer += event.delta; + break; + case "text-delta": + this.textBuffer += event.delta; + break; + case "tool-call": + this.parts.push({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + arguments: event.input, + }); + break; + } + } + + get(): z.infer { + this.flushBuffers(); + return { + role: "assistant", + content: this.parts, + }; + } +} + +function loadFunction(id: string) { + const func = FunctionsRegistry[id]; + if (!func) { + throw new Error(`Function ${id} not found`); + } + return func; +} + +export async function* executeWorkflow(id: string, input: string, background: boolean = false): AsyncGenerator, void, unknown> { + try { + const workflow = loadWorkflow(id); + const runId = await randomId(); + + yield { + type: "workflow-start", + workflowId: id, + workflow: workflow, + background: background, + }; + + const logger = new RunLogger(id, runId); + + const messages: z.infer = [{ + role: "user", + content: input ?? "" + }]; + + try { + let stepIndex = 0; + + while (true) { + const step = workflow.steps[stepIndex]; + const node = step.type === "agent" ? new AgentNode(step.id, background) : loadFunction(step.id); + const messageBuilder = new StreamStepMessageBuilder(); + + // stream response from agent + for await (const event of node.execute(messages)) { + // console.log(" - event", JSON.stringify(event)); + messageBuilder.ingest(event); + yield { + type: "workflow-step-stream-event", + stepId: step.id, + event: event, + }; + } + + // build and emit final message from agent response + const msg = messageBuilder.get(); + logger.log(msg); + messages.push(msg); + yield { + type: "workflow-step-message", + stepId: step.id, + message: msg, + }; + + // if the agent response contains tool calls, execute them + const tools = node.tools(); + let hasToolCalls = false; + if (msg.content instanceof Array) { + for (const part of msg.content) { + if (part.type === "tool-call") { + hasToolCalls = true; + if (!(part.toolName in tools)) { + throw new Error(`Tool ${part.toolName} not found`); + } + yield { + type: "workflow-step-tool-invocation", + stepId: step.id, + toolName: part.toolName, + input: part.arguments, + } + const result = await execTool(tools[part.toolName], part.arguments); + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: part.toolCallId, + toolName: part.toolName, + }; + logger.log(resultMsg); + messages.push(resultMsg); + yield { + type: "workflow-step-tool-result", + stepId: step.id, + toolName: part.toolName, + result: result, + }; + yield { + type: "workflow-step-message", + stepId: step.id, + message: resultMsg, + }; + } + } + } + + // if the agent response had tool calls, replay this agent + if (hasToolCalls) { + continue; + } + + // otherwise, move to the next step + stepIndex++; + if (stepIndex >= workflow.steps.length) { + break; + } + } + } finally { + logger.close(); + } + + // console.log('\n\n', JSON.stringify(messages, null, 2)); + } catch (error) { + yield { + type: "workflow-error", + error: error instanceof Error ? error.message : String(error), + }; + } finally { + yield { + type: "workflow-end", + }; + } +} \ No newline at end of file diff --git a/apps/cli/src/application/lib/step.ts b/apps/cli/src/application/lib/step.ts new file mode 100644 index 00000000..ec3c2146 --- /dev/null +++ b/apps/cli/src/application/lib/step.ts @@ -0,0 +1,13 @@ +import { MessageList } from "../entities/message.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; +import { z } from "zod"; +import { AgentTool } from "../entities/agent.js"; + +export type StepInputT = z.infer; +export type StepOutputT = AsyncGenerator, void, unknown>; + +export interface Step { + execute(input: StepInputT): StepOutputT; + + tools(): Record>; +} \ No newline at end of file diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index a8d69d7b..0bf06986 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { StreamEvent } from "../entities/stream-event.js"; +import { WorkflowStreamEvent } from "../entities/workflow-event.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; export interface StreamRendererOptions { showHeaders?: boolean; @@ -23,7 +24,48 @@ export class StreamRenderer { }; } - render(event: z.infer) { + render(event: z.infer) { + switch (event.type) { + case "workflow-start": { + this.onWorkflowStart(event.workflowId, event.background); + break; + } + case "workflow-step-start": { + this.onStepStart(event.stepId, event.stepType); + break; + } + case "workflow-step-stream-event": { + this.renderLlmEvent(event.event); + break; + } + case "workflow-step-message": { + // this.onStepMessage(event.stepId, event.message); + break; + } + case "workflow-step-tool-invocation": { + this.onStepToolInvocation(event.stepId, event.toolName, event.input); + break; + } + case "workflow-step-tool-result": { + this.onStepToolResult(event.stepId, event.toolName, event.result); + break; + } + case "workflow-step-end": { + this.onStepEnd(event.stepId); + break; + } + case "workflow-end": { + this.onWorkflowEnd(); + break; + } + case "workflow-error": { + this.onWorkflowError(event.error); + break; + } + } + } + + private renderLlmEvent(event: z.infer) { switch (event.type) { case "reasoning-start": this.onReasoningStart(); @@ -52,6 +94,58 @@ export class StreamRenderer { } } + private onWorkflowStart(workflowId: string, background: boolean) { + this.write("\n"); + this.write(this.bold(`▶ Workflow ${workflowId}`)); + if (background) this.write(this.dim(" (background)")); + this.write("\n"); + } + + private onWorkflowEnd() { + this.write(this.bold("\n■ Workflow complete\n")); + } + + private onWorkflowError(error: string) { + this.write(this.red(`\n✖ Workflow error: ${error}\n`)); + } + + private onStepStart(stepId: string, stepType: "agent" | "function") { + this.write("\n"); + this.write(this.cyan(`─ Step ${stepId} [${stepType}]`)); + this.write("\n"); + } + + private onStepEnd(stepId: string) { + this.write(this.dim(`✓ Step ${stepId} finished\n`)); + } + + private onStepMessage(stepId: string, message: any) { + const role = message?.role ?? "message"; + const content = message?.content; + this.write(this.bold(`${role}: `)); + if (typeof content === "string") { + this.write(content + "\n"); + } else { + const pretty = this.truncate(JSON.stringify(message, null, this.options.jsonIndent)); + this.write(this.dim("\n" + this.indent(pretty) + "\n")); + } + } + + private onStepToolInvocation(stepId: string, toolName: string, input: string) { + this.write(this.cyan(`\n→ Tool invoke ${toolName}`)); + if (input && input.length) { + this.write("\n" + this.dim(this.indent(this.truncate(input))) + "\n"); + } else { + this.write("\n"); + } + } + + private onStepToolResult(stepId: string, toolName: string, result: unknown) { + const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent)); + this.write(this.cyan(`\n← Tool result ${toolName}\n`)); + this.write(this.dim(this.indent(res)) + "\n"); + } + private onReasoningStart() { if (this.reasoningActive) return; this.reasoningActive = true; @@ -146,6 +240,10 @@ export class StreamRenderer { private cyan(text: string): string { return "\x1b[36m" + text + "\x1b[0m"; } + + private red(text: string): string { + return "\x1b[31m" + text + "\x1b[0m"; + } } diff --git a/apps/cli/src/application/lib/utils.ts b/apps/cli/src/application/lib/utils.ts new file mode 100644 index 00000000..da211b36 --- /dev/null +++ b/apps/cli/src/application/lib/utils.ts @@ -0,0 +1,10 @@ +import fs from "fs"; +import path from "path"; +import { WorkDir } from "../config/config.js"; +import { Workflow } from "../entities/workflow.js"; + +export function loadWorkflow(id: string) { + const workflowPath = path.join(WorkDir, "workflows", `${id}.json`); + const workflow = fs.readFileSync(workflowPath, "utf8"); + return Workflow.parse(JSON.parse(workflow)); +} diff --git a/apps/cli/src/application/nodes/node.ts b/apps/cli/src/application/nodes/node.ts deleted file mode 100644 index 5d5d3186..00000000 --- a/apps/cli/src/application/nodes/node.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MessageList } from "../entities/message.js"; -import { StreamEvent } from "../entities/stream-event.js"; -import { z } from "zod"; - -export type NodeInputT = z.infer; -export type NodeOutputT = AsyncGenerator, void, unknown>; - -export interface Node { - execute(input: NodeInputT): NodeOutputT; -} \ No newline at end of file diff --git a/apps/cli/src/application/registry/functions.ts b/apps/cli/src/application/registry/functions.ts index 3b131c30..1d4c1a9b 100644 --- a/apps/cli/src/application/registry/functions.ts +++ b/apps/cli/src/application/registry/functions.ts @@ -1,6 +1,6 @@ import { GetDate } from "../functions/get_date.js"; -import { Node } from "../nodes/node.js"; +import { Step } from "../lib/step.js"; -export const FunctionsRegistry: Record = { +export const FunctionsRegistry: Record = { get_date: new GetDate(), } as const; \ No newline at end of file From 38a8700fa97bfc6573b76a6a4c50d3a0db48d439 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sat, 8 Nov 2025 09:12:02 +0530 Subject: [PATCH 08/38] disable ask human tool --- apps/cli/src/application/lib/agent.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index ef8321e5..d03936e6 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -138,9 +138,9 @@ export class AgentNode implements Step { async* execute(input: StepInputT): StepOutputT { // console.log("\n\n\t>>>>\t\tinput", JSON.stringify(input)); const tools: ToolSet = {}; - if (!this.background) { - tools["ask-human"] = AskHumanTool; - } + // if (!this.background) { + // tools["ask-human"] = AskHumanTool; + // } for (const [name, tool] of Object.entries(this.agent.tools ?? {})) { try { tools[name] = mapAgentTool(tool); From 9e89a81c8d7e0ea5473cb9822c2af84118f9ed37 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:56:30 +0530 Subject: [PATCH 09/38] support ask-human in sync mode --- apps/cli/src/application/lib/agent.ts | 4 +++- apps/cli/src/application/lib/exec-tool.ts | 26 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index d03936e6..996e0b8e 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -48,6 +48,8 @@ function mapAgentTool(t: z.infer): Tool { switch (t.name) { case "bash": return BashTool; + case "ask-human": + return AskHumanTool; default: throw new Error(`Unknown builtin tool: ${t.name}`); } @@ -154,7 +156,7 @@ export class AgentNode implements Step { const { fullStream } = streamText({ model: openai("gpt-4.1"), - // model: google("gemini-2.5-pro"), + // model: google("gemini-2.5-flash"), messages: convertFromMessages(input), system: this.agent.instructions, stopWhen: stepCountIs(1), diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index e703fd41..1da589ad 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -12,6 +12,7 @@ import { executeCommand } from "./command-executor.js"; import { loadWorkflow } from "./utils.js"; import { AssistantMessage } from "../entities/message.js"; import { executeWorkflow } from "./exec-workflow.js"; +import readline from "readline"; async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { // load mcp configuration from the tool @@ -65,6 +66,22 @@ async function execBashTool(agentTool: z.infer, input: any): P }; } +async function execAskHumanTool(agentTool: z.infer, input: any): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + let p = new Promise((resolve, reject) => { + rl.question(`>> Provide answer to: ${input.question}:\n\n`, (answer) => { + resolve(answer); + rl.close(); + }); + }); + const answer = await p; + return answer; +} + async function execWorkflowTool(agentTool: z.infer & { type: "workflow" }, input: any): Promise { let lastMsg: z.infer | null = null; for await (const event of executeWorkflow(agentTool.name, input.message)) { @@ -97,6 +114,13 @@ export async function execTool(agentTool: z.infer, input: any) case "workflow": return execWorkflowTool(agentTool, input); case "builtin": - return execBashTool(agentTool, input); + switch (agentTool.name) { + case "bash": + return execBashTool(agentTool, input); + case "ask-human": + return execAskHumanTool(agentTool, input); + default: + throw new Error(`Unknown builtin tool: ${agentTool.name}`); + } } } \ No newline at end of file From 54bdbe73c0f05036ffd4072fe7bb390735024869 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:32:46 +0530 Subject: [PATCH 10/38] complete ask-human implementation --- apps/cli/src/app.ts | 118 ++++- .../application/entities/workflow-event.ts | 81 ++-- apps/cli/src/application/lib/exec-tool.ts | 10 +- apps/cli/src/application/lib/exec-workflow.ts | 457 +++++++++++++----- .../src/application/lib/stream-renderer.ts | 44 +- 5 files changed, 529 insertions(+), 181 deletions(-) diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 4198972a..2945956c 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -1,15 +1,117 @@ -import { executeWorkflow } from "./application/lib/exec-workflow.js"; +import { executeWorkflow, resumeWorkflow } from "./application/lib/exec-workflow.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +type ParsedArgs = { + command: "run" | "resume" | "help" | null; + id: string | null; + interactive: boolean; + message: string; +}; -async function runWorkflow(id: string, userInput: string) { - const renderer = new StreamRenderer(); - for await (const event of executeWorkflow(id, userInput)) { - renderer.render(event); +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2); + if (args.length === 0) { + return { command: "help", id: null, interactive: true, message: "" }; + } + + let command: ParsedArgs["command"] = null; + let id: string | null = null; + let interactive = true; + const messageParts: string[] = []; + + if (args[0] !== "run" && args[0] !== "resume") { + command = "help"; + return { command, id: null, interactive, message: "" }; + } + command = args[0]; + + for (let i = 1; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + if (a === "--no-interactive") { + interactive = false; + } else if (a.startsWith("--interactive")) { + const [, value] = a.split("="); + if (value === undefined) { + interactive = true; + } else { + interactive = value !== "false"; + } + } + continue; + } + if (!id) { + id = a; + continue; + } + messageParts.push(a); + } + + return { command, id, interactive, message: messageParts.join(" ") }; +} + +function printUsage(): void { + console.log([ + "Usage:", + " rowboatx run [message...] [--interactive | --no-interactive]", + " rowboatx resume [message...] [--interactive | --no-interactive]", + "", + "Flags:", + " --interactive Run interactively (default: true)", + " --no-interactive Disable interactive prompts", + ].join("\n")); +} + +async function promptForResumeInput(): Promise { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question("Enter input to resume the run: "); + return answer; + } finally { + rl.close(); } } -const workflowId = process.argv[2] ?? "example_workflow"; -const userInputMsg = process.argv[3] ?? ""; +async function render(generator: AsyncGenerator): Promise { + const renderer = new StreamRenderer(); + for await (const event of generator) { + renderer.render(event); + if (event?.type === "error") { + process.exitCode = 1; + } + } +} -runWorkflow(workflowId, userInputMsg); \ No newline at end of file +async function main() { + const { command, id, interactive, message } = parseArgs(process.argv); + + if (command === "help" || !command) { + printUsage(); + return; + } + if (!id) { + printUsage(); + process.exitCode = 1; + return; + } + + switch (command) { + case "run": { + const initialInput = message ?? ""; + await render(executeWorkflow(id, initialInput, interactive)); + break; + } + case "resume": { + const resumeInput = message !== "" ? message : (interactive ? await promptForResumeInput() : ""); + await render(resumeWorkflow(id, resumeInput, interactive)); + break; + } + } +} + +main().catch((err) => { + console.error("Failed:", err instanceof Error ? err.message : String(err)); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/apps/cli/src/application/entities/workflow-event.ts b/apps/cli/src/application/entities/workflow-event.ts index 4811db11..2e463836 100644 --- a/apps/cli/src/application/entities/workflow-event.ts +++ b/apps/cli/src/application/entities/workflow-event.ts @@ -3,67 +3,84 @@ import { LlmStepStreamEvent } from "./llm-step-event.js"; import { Workflow } from "./workflow.js"; import { Message } from "./message.js"; -export const WorkflowStreamStartEvent = z.object({ - type: z.literal("workflow-start"), - workflowId: z.string(), - workflow: Workflow, - background: z.boolean(), +const BaseRunEvent = z.object({ + ts: z.iso.datetime().optional(), }); -export const WorkflowStreamStepStartEvent = z.object({ - type: z.literal("workflow-step-start"), +export const RunStartEvent = BaseRunEvent.extend({ + type: z.literal("start"), + runId: z.string(), + workflowId: z.string(), + workflow: Workflow, + interactive: z.boolean(), +}); + +export const RunStepStartEvent = BaseRunEvent.extend({ + type: z.literal("step-start"), + stepIndex: z.number(), stepId: z.string(), stepType: z.enum(["agent", "function"]), }); -export const WorkflowStreamStepStreamEventEvent = z.object({ - type: z.literal("workflow-step-stream-event"), +export const RunStreamEvent = BaseRunEvent.extend({ + type: z.literal("stream-event"), stepId: z.string(), event: LlmStepStreamEvent, }); -export const WorkflowStreamStepMessageEvent = z.object({ - type: z.literal("workflow-step-message"), +export const RunMessageEvent = BaseRunEvent.extend({ + type: z.literal("message"), stepId: z.string(), message: Message, }); -export const WorkflowStreamStepToolInvocationEvent = z.object({ - type: z.literal("workflow-step-tool-invocation"), +export const RunToolInvocationEvent = BaseRunEvent.extend({ + type: z.literal("tool-invocation"), stepId: z.string(), toolName: z.string(), input: z.string(), }); -export const WorkflowStreamStepToolResultEvent = z.object({ - type: z.literal("workflow-step-tool-result"), +export const RunToolResultEvent = BaseRunEvent.extend({ + type: z.literal("tool-result"), stepId: z.string(), toolName: z.string(), result: z.any(), }); -export const WorkflowStreamStepEndEvent = z.object({ - type: z.literal("workflow-step-end"), - stepId: z.string(), +export const RunStepEndEvent = BaseRunEvent.extend({ + type: z.literal("step-end"), + stepIndex: z.number(), }); -export const WorkflowStreamEndEvent = z.object({ - type: z.literal("workflow-end"), +export const RunEndEvent = BaseRunEvent.extend({ + type: z.literal("end"), }); -export const WorkflowStreamErrorEvent = z.object({ - type: z.literal("workflow-error"), +export const RunPauseEvent = BaseRunEvent.extend({ + type: z.literal("pause-for-human-input"), + toolCallId: z.string(), +}); + +export const RunResumeEvent = BaseRunEvent.extend({ + type: z.literal("resume"), +}); + +export const RunErrorEvent = BaseRunEvent.extend({ + type: z.literal("error"), error: z.string(), }); -export const WorkflowStreamEvent = z.union([ - WorkflowStreamStartEvent, - WorkflowStreamStepStartEvent, - WorkflowStreamStepStreamEventEvent, - WorkflowStreamStepMessageEvent, - WorkflowStreamStepToolInvocationEvent, - WorkflowStreamStepToolResultEvent, - WorkflowStreamStepEndEvent, - WorkflowStreamEndEvent, - WorkflowStreamErrorEvent, +export const RunEvent = z.union([ + RunStartEvent, + RunStepStartEvent, + RunStreamEvent, + RunMessageEvent, + RunToolInvocationEvent, + RunToolResultEvent, + RunStepEndEvent, + RunEndEvent, + RunPauseEvent, + RunResumeEvent, + RunErrorEvent, ]); \ No newline at end of file diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index 1da589ad..b6485355 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -66,14 +66,14 @@ async function execBashTool(agentTool: z.infer, input: any): P }; } -async function execAskHumanTool(agentTool: z.infer, input: any): Promise { +export async function execAskHumanTool(agentTool: z.infer, question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let p = new Promise((resolve, reject) => { - rl.question(`>> Provide answer to: ${input.question}:\n\n`, (answer) => { + rl.question(`>> Provide answer to: ${question}:\n\n`, (answer) => { resolve(answer); rl.close(); }); @@ -85,10 +85,10 @@ async function execAskHumanTool(agentTool: z.infer, input: any async function execWorkflowTool(agentTool: z.infer & { type: "workflow" }, input: any): Promise { let lastMsg: z.infer | null = null; for await (const event of executeWorkflow(agentTool.name, input.message)) { - if (event.type === "workflow-step-message" && event.message.role === "assistant") { + if (event.type === "message" && event.message.role === "assistant") { lastMsg = event.message; } - if (event.type === "workflow-error") { + if (event.type === "error") { throw new Error(event.error); } } @@ -117,8 +117,6 @@ export async function execTool(agentTool: z.infer, input: any) switch (agentTool.name) { case "bash": return execBashTool(agentTool, input); - case "ask-human": - return execAskHumanTool(agentTool, input); default: throw new Error(`Unknown builtin tool: ${agentTool.name}`); } diff --git a/apps/cli/src/application/lib/exec-workflow.ts b/apps/cli/src/application/lib/exec-workflow.ts index 39c620d1..8c7c0ea9 100644 --- a/apps/cli/src/application/lib/exec-workflow.ts +++ b/apps/cli/src/application/lib/exec-workflow.ts @@ -1,15 +1,61 @@ import { loadWorkflow } from "./utils.js"; -import { randomId } from "./random-id.js"; -import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage } from "../entities/message.js"; +import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage, ToolCallPart } from "../entities/message.js"; import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; import { AgentNode } from "./agent.js"; import { z } from "zod"; import path from "path"; import { WorkDir } from "../config/config.js"; import fs from "fs"; +import { createInterface, Interface } from "node:readline/promises"; import { FunctionsRegistry } from "../registry/functions.js"; -import { WorkflowStreamEvent } from "../entities/workflow-event.js"; -import { execTool } from "./exec-tool.js"; +import { RunEvent } from "../entities/workflow-event.js"; +import { execAskHumanTool, execTool } from "./exec-tool.js"; +import { AgentTool } from "../entities/agent.js"; +import { runIdGenerator } from "./run-id-gen.js"; +import { Workflow } from "../entities/workflow.js"; + +const MappedToolCall = z.object({ + toolCall: ToolCallPart, + agentTool: AgentTool, +}); + +const State = z.object({ + stepIndex: z.number(), + messages: MessageList, + workflow: Workflow.nullable(), + pendingToolCallId: z.string().nullable(), +}); + +class StateBuilder { + private state: z.infer = { + stepIndex: 0, + messages: [], + workflow: null, + pendingToolCallId: null, + }; + + ingest(event: z.infer) { + switch (event.type) { + case "start": + this.state.workflow = event.workflow; + break; + case "step-start": + this.state.stepIndex = event.stepIndex; + break; + case "message": + this.state.messages.push(event.message); + this.state.pendingToolCallId = null; + break; + case "pause-for-human-input": + this.state.pendingToolCallId = event.toolCallId; + break; + } + } + + get(): z.infer { + return this.state; + } +} class RunLogger { private logFile: string; @@ -24,15 +70,15 @@ class RunLogger { constructor(workflowId: string, runId: string) { this.ensureRunsDir(workflowId); - this.logFile = path.join(WorkDir, "runs", `${workflowId}`, `${runId}.jsonl`); + this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); this.fileHandle = fs.createWriteStream(this.logFile, { flags: "a", encoding: "utf8", }); } - log(message: z.infer) { - this.fileHandle.write(JSON.stringify(message) + "\n"); + log(event: z.infer) { + this.fileHandle.write(JSON.stringify(event) + "\n"); } close() { @@ -40,6 +86,23 @@ class RunLogger { } } +class LogAndYield { + private logger: RunLogger + + constructor(logger: RunLogger) { + this.logger = logger; + } + + async *logAndYield(event: z.infer): AsyncGenerator, void, unknown> { + const ev = { + ...event, + ts: new Date().toISOString(), + } + this.logger.log(ev); + yield ev; + } +} + class StreamStepMessageBuilder { private parts: z.infer[] = []; private textBuffer: string = ""; @@ -98,118 +161,286 @@ function loadFunction(id: string) { return func; } -export async function* executeWorkflow(id: string, input: string, background: boolean = false): AsyncGenerator, void, unknown> { +export async function* executeWorkflow(id: string, input: string, interactive: boolean = true): AsyncGenerator, void, unknown> { + const runId = runIdGenerator.next(); + yield* runFromState({ + id, + runId, + state: { + stepIndex: 0, + messages: [{ + role: "user", + content: input, + }], + workflow: null, + pendingToolCallId: null, + }, + interactive, + }); +} + +export async function* resumeWorkflow(runId: string, input: string, interactive: boolean = false): AsyncGenerator, void, unknown> { + // read a run.jsonl file line by line and build state + const builder = new StateBuilder(); + let rl: Interface | null = null; + let stream: fs.ReadStream | null = null; try { - const workflow = loadWorkflow(id); - const runId = await randomId(); - - yield { - type: "workflow-start", - workflowId: id, - workflow: workflow, - background: background, - }; - - const logger = new RunLogger(id, runId); - - const messages: z.infer = [{ - role: "user", - content: input ?? "" - }]; - - try { - let stepIndex = 0; - - while (true) { - const step = workflow.steps[stepIndex]; - const node = step.type === "agent" ? new AgentNode(step.id, background) : loadFunction(step.id); - const messageBuilder = new StreamStepMessageBuilder(); - - // stream response from agent - for await (const event of node.execute(messages)) { - // console.log(" - event", JSON.stringify(event)); - messageBuilder.ingest(event); - yield { - type: "workflow-step-stream-event", - stepId: step.id, - event: event, - }; - } - - // build and emit final message from agent response - const msg = messageBuilder.get(); - logger.log(msg); - messages.push(msg); - yield { - type: "workflow-step-message", - stepId: step.id, - message: msg, - }; - - // if the agent response contains tool calls, execute them - const tools = node.tools(); - let hasToolCalls = false; - if (msg.content instanceof Array) { - for (const part of msg.content) { - if (part.type === "tool-call") { - hasToolCalls = true; - if (!(part.toolName in tools)) { - throw new Error(`Tool ${part.toolName} not found`); - } - yield { - type: "workflow-step-tool-invocation", - stepId: step.id, - toolName: part.toolName, - input: part.arguments, - } - const result = await execTool(tools[part.toolName], part.arguments); - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(result), - toolCallId: part.toolCallId, - toolName: part.toolName, - }; - logger.log(resultMsg); - messages.push(resultMsg); - yield { - type: "workflow-step-tool-result", - stepId: step.id, - toolName: part.toolName, - result: result, - }; - yield { - type: "workflow-step-message", - stepId: step.id, - message: resultMsg, - }; - } - } - } - - // if the agent response had tool calls, replay this agent - if (hasToolCalls) { - continue; - } - - // otherwise, move to the next step - stepIndex++; - if (stepIndex >= workflow.steps.length) { - break; - } + const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); + stream = fs.createReadStream(logFile, { encoding: "utf8" }); + rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (line.trim() === "") { + continue; } - } finally { - logger.close(); + // console.error('processing line', line); + const parsed = JSON.parse(line); + // console.error('parsed'); + const event = RunEvent.parse(parsed); + // console.error('zod parsed'); + builder.ingest(event); + } + } catch (error) { + // console.error("Failed to resume workflow:", error); + // yield { + // type: "error", + // error: error instanceof Error ? error.message : String(error), + // }; + } finally { + rl?.close(); + stream?.close(); + } + + const { workflow, messages, stepIndex, pendingToolCallId } = builder.get(); + if (!workflow) { + throw new Error(`Workflow not found for run ${runId}`); + } + if (!pendingToolCallId) { + throw new Error(`No pending tool call found for run ${runId}`); + } + const stepId = workflow.steps[stepIndex].id; + + // append user input as message + const logger = new RunLogger(workflow.name, runId); + const ly = new LogAndYield(logger); + yield *ly.logAndYield({ + type: "resume" + }); + + // append user input as message + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(input), + toolCallId: pendingToolCallId, + toolName: "ask-human", + }; + messages.push(resultMsg); + yield* ly.logAndYield({ + type: "tool-result", + stepId, + toolName: "ask-human", + result: input, + }); + yield* ly.logAndYield({ + type: "message", + stepId, + message: resultMsg, + }); + + yield* runFromState({ + id: workflow.name, + runId, + state: { + stepIndex, + messages, + workflow, + pendingToolCallId, + }, + interactive, + }); +} + +async function* runFromState(opts: { + id: string; + runId: string; + state: z.infer; + interactive: boolean; +}) { + const { id, runId, state, interactive } = opts; + let stepIndex = state.stepIndex; + let messages = [...state.messages]; + let workflow = state.workflow; + + const logger = new RunLogger(id, runId); + const ly = new LogAndYield(logger); + + try { + if (!workflow) { + workflow = loadWorkflow(id); + + yield* ly.logAndYield({ + type: "start", + runId, + workflowId: id, + workflow, + interactive, + }); } + while (true) { + const step = workflow.steps[stepIndex]; + const node = step.type === "agent" ? new AgentNode(step.id, interactive) : loadFunction(step.id); + + yield* ly.logAndYield({ + type: "step-start", + stepIndex, + stepId: step.id, + stepType: step.type, + }); + + const messageBuilder = new StreamStepMessageBuilder(); + + // stream response from agent + for await (const event of node.execute(messages)) { + // console.log(" - event", JSON.stringify(event)); + messageBuilder.ingest(event); + yield* ly.logAndYield({ + type: "stream-event", + stepId: step.id, + event: event, + }); + } + + // build and emit final message from agent response + const msg = messageBuilder.get(); + messages.push(msg); + yield* ly.logAndYield({ + type: "message", + stepId: step.id, + message: msg, + }); + + // handle tool calls + const tools = node.tools(); + const mappedToolCalls: z.infer[] = []; + let msgToolCallParts: z.infer[] = []; + if (msg.content instanceof Array) { + msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); + } + const hasToolCalls = msgToolCallParts.length > 0; + + // validate and map tool calls + for (const part of msgToolCallParts) { + const agentTool = tools[part.toolName]; + if (!agentTool) { + throw new Error(`Tool ${part.toolName} not found`); + } + mappedToolCalls.push({ + toolCall: part, + agentTool: agentTool, + }); + } + + // first, exec all tool calls other than ask-human + for (const call of mappedToolCalls) { + const { agentTool, toolCall } = call; + if (agentTool.type === "builtin" && agentTool.name === "ask-human") { + continue; + } + yield* ly.logAndYield({ + type: "tool-invocation", + stepId: step.id, + toolName: toolCall.toolName, + input: JSON.stringify(toolCall.arguments), + }); + const result = await execTool(agentTool, toolCall.arguments); + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; + messages.push(resultMsg); + yield* ly.logAndYield({ + type: "tool-result", + stepId: step.id, + toolName: toolCall.toolName, + result: result, + }); + yield* ly.logAndYield({ + type: "message", + stepId: step.id, + message: resultMsg, + }); + } + + // handle ask-tool call execution + for (const call of mappedToolCalls) { + const { agentTool, toolCall } = call; + if (agentTool.type !== "builtin" || agentTool.name !== "ask-human") { + continue; + } + yield* ly.logAndYield({ + type: "tool-invocation", + stepId: step.id, + toolName: toolCall.toolName, + input: JSON.stringify(toolCall.arguments), + }); + + // if running in background mode, exit here + if (!interactive) { + yield* ly.logAndYield({ + type: "pause-for-human-input", + toolCallId: toolCall.toolCallId, + }); + return; + } + const result = await execAskHumanTool(agentTool, toolCall.arguments.question as string); + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; + messages.push(resultMsg); + yield* ly.logAndYield({ + type: "tool-result", + stepId: step.id, + toolName: toolCall.toolName, + result: result, + }); + yield* ly.logAndYield({ + type: "message", + stepId: step.id, + message: resultMsg, + }); + } + + yield* ly.logAndYield({ + type: "step-end", + stepIndex, + }); + + // if the agent response had tool calls, replay this agent + if (hasToolCalls) { + continue; + } + + // otherwise, move to the next step + stepIndex++; + if (stepIndex >= workflow.steps.length) { + yield* ly.logAndYield({ + type: "end", + }); + break; + } + } // console.log('\n\n', JSON.stringify(messages, null, 2)); } catch (error) { - yield { - type: "workflow-error", + yield* ly.logAndYield({ + type: "error", error: error instanceof Error ? error.message : String(error), - }; + }); } finally { - yield { - type: "workflow-end", - }; + logger.close(); } -} \ No newline at end of file +} diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index 0bf06986..b5a2cc57 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { WorkflowStreamEvent } from "../entities/workflow-event.js"; +import { RunEvent } from "../entities/workflow-event.js"; import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; export interface StreamRendererOptions { @@ -24,41 +24,41 @@ export class StreamRenderer { }; } - render(event: z.infer) { + render(event: z.infer) { switch (event.type) { - case "workflow-start": { - this.onWorkflowStart(event.workflowId, event.background); + case "start": { + this.onWorkflowStart(event.workflowId, event.runId, event.interactive); break; } - case "workflow-step-start": { - this.onStepStart(event.stepId, event.stepType); + case "step-start": { + this.onStepStart(event.stepIndex, event.stepId, event.stepType); break; } - case "workflow-step-stream-event": { + case "stream-event": { this.renderLlmEvent(event.event); break; } - case "workflow-step-message": { + case "message": { // this.onStepMessage(event.stepId, event.message); break; } - case "workflow-step-tool-invocation": { + case "tool-invocation": { this.onStepToolInvocation(event.stepId, event.toolName, event.input); break; } - case "workflow-step-tool-result": { + case "tool-result": { this.onStepToolResult(event.stepId, event.toolName, event.result); break; } - case "workflow-step-end": { - this.onStepEnd(event.stepId); + case "step-end": { + this.onStepEnd(event.stepIndex); break; } - case "workflow-end": { + case "end": { this.onWorkflowEnd(); break; } - case "workflow-error": { + case "error": { this.onWorkflowError(event.error); break; } @@ -94,10 +94,10 @@ export class StreamRenderer { } } - private onWorkflowStart(workflowId: string, background: boolean) { + private onWorkflowStart(workflowId: string, runId: string, interactive: boolean) { this.write("\n"); - this.write(this.bold(`▶ Workflow ${workflowId}`)); - if (background) this.write(this.dim(" (background)")); + this.write(this.bold(`▶ Workflow ${workflowId} (run ${runId})`)); + if (!interactive) this.write(this.dim(" (--no-interactive)")); this.write("\n"); } @@ -109,17 +109,17 @@ export class StreamRenderer { this.write(this.red(`\n✖ Workflow error: ${error}\n`)); } - private onStepStart(stepId: string, stepType: "agent" | "function") { + private onStepStart(stepIndex: number, stepId: string, stepType: "agent" | "function") { this.write("\n"); - this.write(this.cyan(`─ Step ${stepId} [${stepType}]`)); + this.write(this.cyan(`─ Step ${stepIndex} [${stepType}]`)); this.write("\n"); } - private onStepEnd(stepId: string) { - this.write(this.dim(`✓ Step ${stepId} finished\n`)); + private onStepEnd(stepIndex: number) { + this.write(this.dim(`✓ Step ${stepIndex} finished\n`)); } - private onStepMessage(stepId: string, message: any) { + private onStepMessage(stepIndex: number, message: any) { const role = message?.role ?? "message"; const content = message?.content; this.write(this.bold(`${role}: `)); From 88fc585cc2d2fc113a9e7d88e5790e9161b69179 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:38:27 +0530 Subject: [PATCH 11/38] skip ask-human when running workflow as tool --- apps/cli/src/application/lib/agent.ts | 9 ++++++--- apps/cli/src/application/lib/exec-workflow.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 996e0b8e..4f858b93 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -122,12 +122,12 @@ function convertFromMessages(messages: z.infer[]): ModelMessage[ export class AgentNode implements Step { private id: string; - private background: boolean; + private asTool: boolean; private agent: z.infer; - constructor(id: string, background: boolean) { + constructor(id: string, asTool: boolean) { this.id = id; - this.background = background; + this.asTool = asTool; const agentPath = path.join(WorkDir, "agents", `${id}.json`); const agent = fs.readFileSync(agentPath, "utf8"); this.agent = Agent.parse(JSON.parse(agent)); @@ -144,6 +144,9 @@ export class AgentNode implements Step { // tools["ask-human"] = AskHumanTool; // } for (const [name, tool] of Object.entries(this.agent.tools ?? {})) { + if (this.asTool && name === "ask-human") { + continue; + } try { tools[name] = mapAgentTool(tool); } catch (error) { diff --git a/apps/cli/src/application/lib/exec-workflow.ts b/apps/cli/src/application/lib/exec-workflow.ts index 8c7c0ea9..37421665 100644 --- a/apps/cli/src/application/lib/exec-workflow.ts +++ b/apps/cli/src/application/lib/exec-workflow.ts @@ -161,7 +161,7 @@ function loadFunction(id: string) { return func; } -export async function* executeWorkflow(id: string, input: string, interactive: boolean = true): AsyncGenerator, void, unknown> { +export async function* executeWorkflow(id: string, input: string, interactive: boolean = true, asTool: boolean = false): AsyncGenerator, void, unknown> { const runId = runIdGenerator.next(); yield* runFromState({ id, @@ -176,6 +176,7 @@ export async function* executeWorkflow(id: string, input: string, interactive: b pendingToolCallId: null, }, interactive, + asTool, }); } @@ -256,6 +257,7 @@ export async function* resumeWorkflow(runId: string, input: string, interactive: pendingToolCallId, }, interactive, + asTool: false, }); } @@ -264,8 +266,9 @@ async function* runFromState(opts: { runId: string; state: z.infer; interactive: boolean; + asTool: boolean; }) { - const { id, runId, state, interactive } = opts; + const { id, runId, state, interactive, asTool } = opts; let stepIndex = state.stepIndex; let messages = [...state.messages]; let workflow = state.workflow; @@ -288,7 +291,7 @@ async function* runFromState(opts: { while (true) { const step = workflow.steps[stepIndex]; - const node = step.type === "agent" ? new AgentNode(step.id, interactive) : loadFunction(step.id); + const node = step.type === "agent" ? new AgentNode(step.id, asTool) : loadFunction(step.id); yield* ly.logAndYield({ type: "step-start", From 80ceba4b11818f288db6c832bab0e7c006173097 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:52:36 +0530 Subject: [PATCH 12/38] fix run-id-gen --- apps/cli/src/application/lib/run-id-gen.ts | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/cli/src/application/lib/run-id-gen.ts diff --git a/apps/cli/src/application/lib/run-id-gen.ts b/apps/cli/src/application/lib/run-id-gen.ts new file mode 100644 index 00000000..4bccf259 --- /dev/null +++ b/apps/cli/src/application/lib/run-id-gen.ts @@ -0,0 +1,32 @@ +class RunIdGenerator { + private lastMs = 0; + private seq = 0; + private readonly pid: string; + private readonly hostTag: string; + + constructor(hostTag: string = "") { + this.pid = String(process.pid).padStart(7, "0"); + this.hostTag = hostTag ? `-${hostTag}` : ""; + } + + /** + * Returns an ISO8601-based, lexicographically sortable id string. + * Example: 2025-11-11T04-36-29Z-0001234-h1-000 + */ + next(): string { + const now = Date.now(); + const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp + this.seq = ms === this.lastMs ? this.seq + 1 : 0; + this.lastMs = ms; + + // Build ISO string (UTC) and remove milliseconds for cleaner filenames + const iso = new Date(ms).toISOString() // e.g. 2025-11-11T04:36:29.123Z + .replace(/\.\d{3}Z$/, "Z") // drop .123 part + .replace(/:/g, "-"); // safe for files: 2025-11-11T04-36-29Z + + const seqStr = String(this.seq).padStart(3, "0"); + return `${iso}-${this.pid}${this.hostTag}-${seqStr}`; + } +} + +export const runIdGenerator = new RunIdGenerator(); \ No newline at end of file From e914aa283238474a722c4ef51e0f666c2b9bec34 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 13 Nov 2025 13:03:54 +0530 Subject: [PATCH 13/38] copilot update: - first version can perform CRUD ops on the .rowboat file --- apps/cli/src/application/assistant/README.md | 7 - apps/cli/src/application/assistant/USAGE.md | 16 - .../application/assistant/agents/service.ts | 37 -- apps/cli/src/application/assistant/chat.ts | 599 ++++++++++++------ .../cli/src/application/assistant/commands.ts | 507 --------------- .../src/application/assistant/mcp/service.ts | 22 - .../application/assistant/services/storage.ts | 44 -- .../assistant/workflows/service.ts | 44 -- apps/cli/src/x.ts | 3 + 9 files changed, 413 insertions(+), 866 deletions(-) delete mode 100644 apps/cli/src/application/assistant/README.md delete mode 100644 apps/cli/src/application/assistant/USAGE.md delete mode 100644 apps/cli/src/application/assistant/agents/service.ts delete mode 100644 apps/cli/src/application/assistant/commands.ts delete mode 100644 apps/cli/src/application/assistant/mcp/service.ts delete mode 100644 apps/cli/src/application/assistant/services/storage.ts delete mode 100644 apps/cli/src/application/assistant/workflows/service.ts diff --git a/apps/cli/src/application/assistant/README.md b/apps/cli/src/application/assistant/README.md deleted file mode 100644 index 4c776c26..00000000 --- a/apps/cli/src/application/assistant/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Rowboat Copilot (demo) - -- Entry point: `npm run copilot` (runs `src/x.ts` after building) -- Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows` -- Uses existing zod schemas for validation; errors bubble up plainly for easy debugging -- Maintains conversational memory within a session and replies in natural language (append `--debug` or set `COPILOT_DEBUG=1` to view raw JSON commands) -- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp` diff --git a/apps/cli/src/application/assistant/USAGE.md b/apps/cli/src/application/assistant/USAGE.md deleted file mode 100644 index 0181a696..00000000 --- a/apps/cli/src/application/assistant/USAGE.md +++ /dev/null @@ -1,16 +0,0 @@ -Quick start - -1. `cd rowboat-V2/apps/cli` -2. `export OPENAI_API_KEY=...` -3. `npm run copilot` - -Example prompts once running: -- `list my workflows` -- `show workflow example_workflow` -- `create a workflow demo that calls function get_date` -- `add an agent step default_assistant to demo` -- `delete the demo workflow` - -While the session is open the copilot keeps conversational context, so you can ask follow-ups such as “what was the first thing I asked?” or “add that step again”. Responses are natural language summaries of the structured actions it performs. - -Need to inspect the underlying JSON command/results? Run in debug mode with `npm run copilot -- --debug` (or set `COPILOT_DEBUG=1`) to keep the raw interpreter output visible. diff --git a/apps/cli/src/application/assistant/agents/service.ts b/apps/cli/src/application/assistant/agents/service.ts deleted file mode 100644 index f18022c0..00000000 --- a/apps/cli/src/application/assistant/agents/service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "zod"; -import { Agent } from "../../entities/agent.js"; -import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; - -export type AgentId = string; - -export function listAgents(): AgentId[] { - return listJson("agents"); -} - -export function getAgent(id: AgentId): z.infer | undefined { - const raw = readJson("agents", id); - if (!raw) return undefined; - return Agent.parse(raw); -} - -export function upsertAgent( - id: AgentId, - value: Partial> -): z.infer { - const existing = readJson("agents", id) as Partial> | undefined; - const merged = { - name: id, - model: "openai:gpt-4o-mini", - description: "", - instructions: "", - ...(existing ?? {}), - ...value, - } satisfies Partial>; - const parsed = Agent.parse(merged); - writeJson("agents", id, parsed); - return parsed; -} - -export function deleteAgent(id: AgentId): boolean { - return deleteJson("agents", id); -} diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index bd3a2d45..12462b5d 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -1,196 +1,417 @@ -import readline from "readline"; +import { streamText, ModelMessage, tool, stepCountIs } from "ai"; import { openai } from "@ai-sdk/openai"; -import { generateObject, streamText } from "ai"; -import type { CoreMessage } from "ai"; -import { - ChatCommand, - ChatCommandT, - CommandOutcome, - executeCommand, -} from "./commands.js"; +import * as readline from "readline/promises"; +import { stdin as input, stdout as output } from "process"; +import { z } from "zod"; +import * as fs from "fs/promises"; +import * as path from "path"; -type ConversationMessage = { - role: "user" | "assistant"; - content: string; -}; +const model = openai("gpt-4.1"); +const rl = readline.createInterface({ input, output }); -const systemPrompt = ` -You are a general-purpose CLI copilot that converts the user's natural language into structured commands the Rowboat assistant runtime can execute, and you can also hold a regular conversation when no command fits. +// Base directory for file operations +const BASE_DIR = "/Users/tusharmagar/.rowboat"; -Rules: -- Only output JSON matching the provided schema. No extra commentary. -- Select the most appropriate action from: help, general_chat, list_workflows, get_workflow, describe_workflows, create_workflow, update_workflow, delete_workflow, list_agents, get_agent, create_agent, update_agent, delete_agent, list_mcp_servers, add_mcp_server, remove_mcp_server, run_workflow, unknown. -- Use describe_workflows with { scope: "all" } to show every workflow, or provide specific ids when the user names particular workflows (including pronouns like "them" or "those" referring to previously listed workflows). -- For actions that need an id (workflow/agent), set "id" to the identifier (e.g. "example_workflow"). -- For create/update actions, only include provided fields in "updates". -- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }. -- Agent shape reminder: { name: string, model: string, description: string, instructions: string }. -- MCP server shape reminder: { name: string, url: string }. -- If the request is ambiguous, set action to "unknown". -- If the user is just chatting or asking for general help or explanations, use action "general_chat" with their full prompt in "query". -`; - -const responseSystemPrompt = ` -You are Skipper, the Rowboat CLI copilot. You maintain an ongoing conversation, remember prior questions, run commands when requested, and give helpful free-form answers when a general reply is appropriate. - -Guidelines: -- Respond in natural language with short, helpful paragraphs or bullet lists when useful. -- Summarise command results plainly (lists, confirmations, errors) and mention next steps when appropriate. -- If a command could not be inferred (action "unknown"), clarify what additional detail is needed or answer the query directly using the conversation history when possible. -- Use the conversation history to answer memory questions (for example "what was the first question I asked?"). -- Avoid repeating the raw JSON command or result unless explicitly asked; focus on what the outcome means. -- Deliver everything requested in one response. Do not say you'll follow up later—include all available details right away. -- For general_chat actions, respond directly to the user's query with the best answer you can provide. -`; - -function buildMessageHistory(history: ConversationMessage[]): CoreMessage[] { - return history.map((message) => ({ - role: message.role, - content: message.content, - })); -} - -async function interpret(input: string, history: ConversationMessage[]): Promise { - const stopSpinner = startSpinner("Analyzing…", { persist: false }); - const conversation: CoreMessage[] = [ - { role: "system", content: systemPrompt }, - ...buildMessageHistory(history), - { role: "user", content: input }, - ]; - - try { - const { object } = await generateObject({ - model: openai("gpt-4.1"), - messages: conversation, - schema: ChatCommand, - }); - return object; - } finally { - stopSpinner(); - } -} - -function startSpinner( - label: string, - options: { persist?: boolean } = {} -): (finalMessage?: string) => void { - const { persist = true } = options; - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"]; - let index = 0; - const render = () => { - const frame = frames[index]; - index = (index + 1) % frames.length; - process.stdout.write(`\r${frame} ${label}`); - }; - render(); - const timer = setInterval(render, 80); - return (finalMessage?: string) => { - clearInterval(timer); - const doneFrame = frames[(index + frames.length - 1) % frames.length]; - const message = finalMessage ?? "done"; - const clearWidth = doneFrame.length + label.length + (persist ? message.length + 3 : 2); - const clear = " ".repeat(clearWidth); - process.stdout.write(`\r${clear}`); - if (persist) { - process.stdout.write(`\r${doneFrame} ${label} ${message}\n`); - } else { - process.stdout.write("\r"); - } - }; -} - -async function renderAssistantResponse( - input: string, - cmd: ChatCommandT, - outcome: CommandOutcome, - history: ConversationMessage[] -): Promise { - const condensedCommand = JSON.stringify(cmd, null, 2); - const condensedResult = JSON.stringify(outcome, null, 2); - - const { textStream } = await streamText({ - model: openai("gpt-4.1"), - messages: [ - { role: "system", content: responseSystemPrompt }, - ...buildMessageHistory(history), - { - role: "user", - content: [ - `Most recent request: ${input}`, - `Interpreter output:\n${condensedCommand}`, - `Command result:\n${condensedResult}`, - ].join("\n\n"), - }, - ], - }); - - let final = ""; - for await (const textChunk of textStream as AsyncIterable) { - const chunk = - typeof textChunk === "string" - ? textChunk - : typeof (textChunk as { value?: string }).value === "string" - ? (textChunk as { value?: string }).value ?? "" - : ""; - if (!chunk) continue; - process.stdout.write(chunk); - final += chunk; - } - - if (!final.endsWith("\n")) { - process.stdout.write("\n"); - } - - return final.trim(); -} - -export async function startCopilot(): Promise { - if (!process.env.OPENAI_API_KEY) { - console.error("OPENAI_API_KEY is not set. Please export it to use chat."); - process.exitCode = 1; - return; - } - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - console.log("XRowboat Copilot (type 'exit' to quit)"); - - const debugMode = process.argv.includes("--debug") || process.env.COPILOT_DEBUG === "1"; - const conversationHistory: ConversationMessage[] = []; - - const ask = () => rl.question("> ", async (line) => { - if (!line || line.trim().toLowerCase() === "exit") { - rl.close(); - return; - } +// Ensure base directory exists +async function ensureBaseDir() { try { - const trimmed = line.trim(); - const cmd = await interpret(trimmed, conversationHistory); - let outcome: CommandOutcome; - try { - outcome = await executeCommand(cmd); - } finally { - // no-op - } - - const historyWithLatestUser: ConversationMessage[] = [ - ...conversationHistory, - { role: "user", content: trimmed }, - ]; - const assistantReply = await renderAssistantResponse(trimmed, cmd, outcome, historyWithLatestUser); - console.log(""); - - if (debugMode) { - console.log("=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2)); - console.log("\n=== Outcome ===\n" + JSON.stringify(outcome, null, 2) + "\n"); - } - - conversationHistory.push({ role: "user", content: trimmed }); - conversationHistory.push({ role: "assistant", content: assistantReply }); - } catch (err) { - console.error("Error:", (err as Error).message); + await fs.access(BASE_DIR); + } catch { + await fs.mkdir(BASE_DIR, { recursive: true }); + console.log(`📁 Created directory: ${BASE_DIR}\n`); } - ask(); - }); - - ask(); +} + +// Export the main copilot function +export async function startCopilot() { + // Conversation history + const messages: ModelMessage[] = []; + + console.log("🤖 Rowboat Copilot - Your Intelligent Workflow Assistant"); + console.log(`📂 Working directory: ${BASE_DIR}`); + console.log("💡 I can help you create, manage, and understand workflows."); + console.log("Type 'exit' to quit\n"); + + // Initialize base directory + await ensureBaseDir(); + + while (true) { + // Get user input + const userInput = await rl.question("You: "); + + // Exit condition + if (userInput.toLowerCase() === "exit" || userInput.toLowerCase() === "quit") { + console.log("\n👋 Goodbye!"); + break; + } + + // Add user message to history + messages.push({ role: "user", content: userInput }); + + // Stream AI response + process.stdout.write("\nCopilot: "); + + let currentStep = 0; + const result = streamText({ + model: model, + messages: messages, + system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. + +REASONING & THINKING: +- Before taking action, think through what the user is asking for and put out a text with your reasoning process and the steps you will take to complete the task. +- Break down complex tasks into clear steps +- Explore existing files/structure before creating new ones +- Explain your reasoning as you work through tasks +- Be proactive in understanding context + +WORKFLOW KNOWLEDGE: +- Workflows are JSON files that orchestrate multiple agents +- Agents are JSON files defining AI assistants with specific tools and instructions +- Tools can be built-in functions or MCP (Model Context Protocol) integrations +- Common structure for workflows: { "name": "workflow_name", "description": "...", "steps": [{"type": "agent", "id": "agent_id"}, ...] } +- Common structure for agents: { "name": "agent_name", "description": "...", "model": "gpt-4o", "instructions": "...", "tools": {...} } + +CRITICAL NAMING AND ORGANIZATION RULES: +- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") +- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json → "name": "workflow_name") +- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"}) +- All three must be identical: filename, JSON "name" field, and workflow step "id" field +- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json) +- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json) +- NEVER create workflows or agents outside these designated folders +- Always maintain this naming and organizational consistency when creating or updating files + +YOUR CAPABILITIES: +1. Explore the directory structure to understand existing workflows/agents +2. Create new workflows and agents following best practices +3. Update existing files intelligently +4. Read and analyze file contents to maintain consistency +5. Suggest improvements and ask clarifying questions when needed + +DELETION RULES: +- When a user asks to delete a WORKFLOW, you MUST: + 1. First read/analyze the workflow to identify which agents it uses + 2. List those agents to the user + 3. Ask the user if they want to delete those agents as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete what the user confirms +- When a user asks to delete an AGENT, you MUST: + 1. First read/analyze the agent to identify which workflows it is used in + 2. List those workflows to the user + 3. Ask the user if they want to delete/modify those workflows as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete/modify what the user confirms + +COMMUNICATION STYLE: +- Start by thinking through the request +- Explain what you're exploring and why +- Show your reasoning process +- Confirm what you've done and suggest next steps +- Be conversational but informative +- Always ask for confirmation before destructive operations!! + +Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, + + tools: { + exploreDirectory: tool({ + description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), + maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), + }), + execute: async ({ subdirectory, maxDepth = 3 }) => { + async function explore(dir: string, depth: number = 0): Promise { + if (depth > maxDepth) return null; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const result: any = { files: [], directories: {} }; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isFile()) { + const ext = path.extname(entry.name); + const size = (await fs.stat(fullPath)).size; + result.files.push({ + name: entry.name, + type: ext || 'no-extension', + size: size, + relativePath: path.relative(BASE_DIR, fullPath), + }); + } else if (entry.isDirectory()) { + result.directories[entry.name] = await explore(fullPath, depth + 1); + } + } + + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const structure = await explore(dirPath); + + return { + success: true, + basePath: path.relative(BASE_DIR, dirPath) || '.', + structure, + }; + }, + }), + + readFile: tool({ + description: 'Read and parse file contents. For JSON files, provides parsed structure.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'), + }), + execute: async ({ filename }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const content = await fs.readFile(filePath, 'utf-8'); + + let parsed = null; + let fileType = path.extname(filename); + + if (fileType === '.json') { + try { + parsed = JSON.parse(content); + } catch { + parsed = { error: 'Invalid JSON' }; + } + } + + return { + success: true, + filename, + fileType, + content, + parsed, + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + createFile: tool({ + description: 'Create a new file with content. Automatically creates parent directories if needed.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'), + content: z.string().describe('The content to write to the file'), + description: z.string().optional().describe('Optional description of why this file is being created'), + }), + execute: async ({ filename, content, description }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const dir = path.dirname(filePath); + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }); + + // Write file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' created successfully`, + description: description || 'No description provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + updateFile: tool({ + description: 'Update or overwrite the contents of an existing file', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'), + content: z.string().describe('The new content to write to the file'), + reason: z.string().optional().describe('Optional reason for the update'), + }), + execute: async ({ filename, content, reason }) => { + try { + const filePath = path.join(BASE_DIR, filename); + + // Check if file exists + await fs.access(filePath); + + // Update file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' updated successfully`, + reason: reason || 'No reason provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + deleteFile: tool({ + description: 'Delete a file from the .rowboat directory', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'), + }), + execute: async ({ filename }) => { + try { + const filePath = path.join(BASE_DIR, filename); + await fs.unlink(filePath); + + return { + success: true, + message: `File '${filename}' deleted successfully`, + path: filePath, + }; + } catch (error) { + return { + success: false, + message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + listFiles: tool({ + description: 'List all files and directories in the .rowboat directory or subdirectory', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'), + }), + execute: async ({ subdirectory }) => { + try { + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + const files = entries + .filter(entry => entry.isFile()) + .map(entry => ({ + name: entry.name, + type: path.extname(entry.name) || 'no-extension', + relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)), + })); + + const directories = entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + + return { + success: true, + path: dirPath, + relativePath: path.relative(BASE_DIR, dirPath) || '.', + files, + directories, + totalFiles: files.length, + totalDirectories: directories.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + analyzeWorkflow: tool({ + description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies', + inputSchema: z.object({ + workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'), + }), + execute: async ({ workflowName }) => { + try { + const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`; + const filePath = path.join(BASE_DIR, 'workflows', filename); + + const content = await fs.readFile(filePath, 'utf-8'); + const workflow = JSON.parse(content); + + // Extract key information + const analysis = { + name: workflow.name, + description: workflow.description || 'No description', + agentCount: workflow.agents ? workflow.agents.length : 0, + agents: workflow.agents || [], + tools: workflow.tools || {}, + structure: workflow, + }; + + return { + success: true, + filePath: path.relative(BASE_DIR, filePath), + analysis, + }; + } catch (error) { + return { + success: false, + message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + }, + + stopWhen: stepCountIs(15), + + onStepFinish: async ({ toolResults }) => { + currentStep++; + + // Show results with clear formatting + if (toolResults && toolResults.length > 0) { + console.log(`\n[Step ${currentStep}]`); + for (const result of toolResults) { + const res = result as any; + console.log(`🔧 Tool: ${res.toolName}`); + + if (res.result && typeof res.result === 'object') { + const resultData = res.result as any; + if (resultData.success) { + console.log(`✅ ${resultData.message || 'Success'}`); + if (resultData.description) console.log(` → ${resultData.description}`); + if (resultData.reason) console.log(` → ${resultData.reason}`); + } else { + console.log(`❌ ${resultData.message || 'Failed'}`); + } + } + } + console.log(); + } + }, + }); + + // Stream and collect response + let assistantResponse = ""; + for await (const textPart of result.textStream) { + process.stdout.write(textPart); + assistantResponse += textPart; + } + console.log("\n"); + + // Add assistant response to history + messages.push({ role: "assistant", content: assistantResponse }); + + // Keep only the last 20 messages (10 user + 10 assistant pairs) + if (messages.length > 20) { + messages.splice(0, messages.length - 20); + } + } + + rl.close(); } diff --git a/apps/cli/src/application/assistant/commands.ts b/apps/cli/src/application/assistant/commands.ts deleted file mode 100644 index 9ccf86ba..00000000 --- a/apps/cli/src/application/assistant/commands.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { z } from "zod"; -import { - listWorkflows, - getWorkflow, - upsertWorkflow, - deleteWorkflow, -} from "./workflows/service.js"; -import { - listAgents, - getAgent, - upsertAgent, - deleteAgent, -} from "./agents/service.js"; -import { - readMcpConfig, - writeMcpConfig, -} from "./mcp/service.js"; -import { Agent } from "../entities/agent.js"; -import { Workflow } from "../entities/workflow.js"; - -export const ChatCommand = z.object({ - action: z.enum([ - "help", - "general_chat", - "list_workflows", - "get_workflow", - "describe_workflows", - "create_workflow", - "update_workflow", - "delete_workflow", - "list_agents", - "get_agent", - "create_agent", - "update_agent", - "delete_agent", - "list_mcp_servers", - "add_mcp_server", - "remove_mcp_server", - "run_workflow", - "unknown", - ]), - id: z.string().optional(), - query: z.string().optional(), - updates: Workflow.partial().optional(), - server: z - .object({ - name: z.string(), - url: z.string(), - }) - .optional(), - name: z.string().optional(), - clarification: z.string().optional(), - ids: z.array(z.string()).optional(), - scope: z.enum(["all"]).optional(), -}); - -export type ChatCommandT = z.infer; - -export type CommandStatus = "ok" | "error"; - -export interface CommandOutcome { - status: CommandStatus; - headline: string; - details?: string; - list?: string[]; - data?: unknown; -} - -function asCommandOutcome( - outcome: Omit & { status?: CommandStatus } -): CommandOutcome { - return { - status: outcome.status ?? "ok", - headline: outcome.headline, - details: outcome.details, - list: outcome.list, - data: outcome.data, - }; -} - -function normalizeKey(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]/g, ""); -} - -function levenshtein(a: string, b: string): number { - if (a === b) return 0; - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - - const matrix: number[][] = Array.from({ length: a.length + 1 }, (_, i) => - Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) - ); - - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + cost // substitution - ); - } - } - - return matrix[a.length][b.length]; -} - -function resolveWorkflowId( - input: string, - existing: string[] -): { id?: string; suggestion?: string } { - const exact = existing.find((candidate) => candidate === input); - if (exact) return { id: exact }; - - const normalizedInput = normalizeKey(input); - const normalizedMap = new Map(); - for (const candidate of existing) { - const key = normalizeKey(candidate); - if (!normalizedMap.has(key)) normalizedMap.set(key, candidate); - } - const normalizedMatch = normalizedMap.get(normalizedInput); - if (normalizedMatch) return { id: normalizedMatch }; - - const ranked = existing - .map((candidate) => ({ - id: candidate, - distance: levenshtein(normalizeKey(candidate), normalizedInput), - })) - .sort((a, b) => a.distance - b.distance); - - const best = ranked[0]; - if (best && best.distance <= 2) { - return { id: best.id }; - } - - return { suggestion: best?.id }; -} - -export async function executeCommand(cmd: ChatCommandT): Promise { - switch (cmd.action) { - case "help": - return asCommandOutcome({ - headline: "Try asking for workflows, agents, or MCP servers.", - list: [ - "list workflows", - "show workflow example_workflow", - "show all workflows in detail", - "create workflow demo that calls function get_date", - "list agents", - "add mcp server staging at http://localhost:8800", - ], - }); - case "list_workflows": { - const items = listWorkflows(); - return asCommandOutcome({ - headline: - items.length === 0 - ? "No workflows saved yet." - : `Found ${items.length} workflow${items.length === 1 ? "" : "s"}.`, - list: items, - data: { items }, - }); - } - case "get_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Provide the workflow name you want to inspect.", - }); - } - const allWorkflows = listWorkflows(); - const { id: resolvedId, suggestion } = resolveWorkflowId(cmd.id, allWorkflows); - if (!resolvedId) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${cmd.id}" was not found.`, - details: suggestion ? `Did you mean "${suggestion}"?` : undefined, - }); - } - const workflow = getWorkflow(resolvedId); - if (!workflow) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${resolvedId}" could not be loaded.`, - }); - } - return asCommandOutcome({ - headline: `Loaded workflow "${resolvedId}".`, - details: workflow.description || "No description set.", - data: workflow, - list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), - }); - } - case "describe_workflows": { - const allWorkflows = listWorkflows(); - const explicitIds = cmd.ids?.map((value) => value.trim()).filter((value) => value.length > 0) ?? []; - const targetIds = - explicitIds.length > 0 ? Array.from(new Set(explicitIds)) : cmd.scope === "all" ? [...allWorkflows] : []; - - if (targetIds.length === 0) { - return asCommandOutcome({ - status: "error", - headline: "No workflows specified.", - details: - explicitIds.length === 0 && cmd.scope !== "all" - ? "Provide workflow ids or set scope to \"all\"." - : "No workflows found to describe.", - }); - } - - const described: Array<{ id: string; workflow: z.infer }> = []; - const missing: string[] = []; - const suggestions: string[] = []; - const seen = new Set(); - - if (explicitIds.length === 0 && cmd.scope === "all") { - for (const id of allWorkflows) { - const workflow = getWorkflow(id); - if (workflow && !seen.has(id)) { - seen.add(id); - described.push({ id, workflow }); - } - } - } else { - for (const requestedId of targetIds) { - const { id: resolvedId, suggestion } = resolveWorkflowId(requestedId, allWorkflows); - if (!resolvedId) { - missing.push(requestedId); - if (suggestion) suggestions.push(`${requestedId} → ${suggestion}`); - continue; - } - if (seen.has(resolvedId)) continue; - seen.add(resolvedId); - const workflow = getWorkflow(resolvedId); - if (workflow) { - described.push({ id: resolvedId, workflow }); - } else { - missing.push(requestedId); - } - } - } - - if (described.length === 0) { - return asCommandOutcome({ - status: "error", - headline: "No workflows found.", - details: `Checked: ${targetIds.join(", ")}`, - }); - } - - const list = described.map(({ workflow }) => { - const description = workflow.description ? workflow.description : "No description set."; - const steps = workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`).join("; "); - return `${workflow.name}: ${description} Steps: ${steps || "None"}.`; - }); - - const details = - missing.length > 0 - ? `Missing workflows: ${missing.join(", ")}.${suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : ""}` - : suggestions.length > 0 - ? `Closest matches: ${suggestions.join(", ")}.` - : undefined; - - return asCommandOutcome({ - headline: `Showing ${described.length} workflow${described.length === 1 ? "" : "s"}.`, - details, - list, - data: { - workflows: described.map(({ workflow }) => workflow), - missing, - }, - }); - } - case "general_chat": - if (!cmd.query) { - return asCommandOutcome({ - status: "error", - headline: "Need the question to answer.", - details: "Repeat your request so I can help.", - }); - } - return asCommandOutcome({ - headline: "General assistance requested.", - details: cmd.query, - data: { query: cmd.query }, - }); - case "create_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to create.", - }); - } - const created = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" saved.`, - data: created, - }); - } - case "update_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to update.", - }); - } - const updated = upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" updated.`, - data: updated, - }); - } - case "delete_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to delete.", - }); - } - const deleted = deleteWorkflow(cmd.id); - return asCommandOutcome({ - headline: deleted - ? `Workflow "${cmd.id}" deleted.` - : `Workflow "${cmd.id}" did not exist.`, - data: { deleted }, - }); - } - case "list_agents": { - const items = listAgents(); - return asCommandOutcome({ - headline: - items.length === 0 - ? "No agents saved yet." - : `Found ${items.length} agent${items.length === 1 ? "" : "s"}.`, - list: items, - data: { items }, - }); - } - case "get_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Provide the agent name you want to inspect.", - }); - } - const agent = getAgent(cmd.id); - if (!agent) { - return asCommandOutcome({ - status: "error", - headline: `Agent "${cmd.id}" was not found.`, - }); - } - return asCommandOutcome({ - headline: `Loaded agent "${cmd.id}".`, - details: agent.description || "No description set.", - data: agent, - }); - } - case "create_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to create.", - }); - } - const created = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Agent "${cmd.id}" saved.`, - data: created, - }); - } - case "update_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to update.", - }); - } - const updated = upsertAgent(cmd.id, { ...(cmd.updates ?? {}) }); - return asCommandOutcome({ - headline: `Agent "${cmd.id}" updated.`, - data: updated, - }); - } - case "delete_agent": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Agent id required.", - details: "Name the agent you want to delete.", - }); - } - const deleted = deleteAgent(cmd.id); - return asCommandOutcome({ - headline: deleted - ? `Agent "${cmd.id}" deleted.` - : `Agent "${cmd.id}" did not exist.`, - data: { deleted }, - }); - } - case "list_mcp_servers": { - const config = readMcpConfig(); - const servers = Object.keys(config.mcpServers); - - const list: string[] = []; - for (const server of servers) { - if ('url' in config.mcpServers[server]) { - list.push(`${server} → ${config.mcpServers[server].url}`); - } else { - list.push(`${server} → ${config.mcpServers[server].command}`); - } - } - - return asCommandOutcome({ - headline: - servers.length === 0 - ? "No MCP servers configured." - : `Found ${servers.length} MCP server${servers.length === 1 ? "" : "s"}.`, - list, - data: servers, - }); - } - case "add_mcp_server": { - const serverConfig = cmd.server; - if (!serverConfig) { - return asCommandOutcome({ - status: "error", - headline: "Server details required.", - details: "Provide a name and url for the MCP server.", - }); - } - const config = readMcpConfig(); - config.mcpServers[serverConfig.name] = { - url: serverConfig.url, - headers: {}, - }; - writeMcpConfig(config); - return asCommandOutcome({ - headline: `MCP server "${serverConfig.name}" saved.`, - data: config.mcpServers, - }); - } - case "remove_mcp_server": { - const name = cmd.name; - if (!name) { - return asCommandOutcome({ - status: "error", - headline: "Server name required.", - details: "Tell me which MCP server to remove.", - }); - } - const config = readMcpConfig(); - delete config.mcpServers[name]; - writeMcpConfig(config); - const removed = name in config.mcpServers; - return asCommandOutcome({ - headline: removed - ? `MCP server "${name}" removed.` - : `MCP server "${name}" was not registered.`, - data: config.mcpServers, - }); - } - case "run_workflow": { - if (!cmd.id) { - return asCommandOutcome({ - status: "error", - headline: "Workflow id required.", - details: "Name the workflow you want to run.", - }); - } - const workflow = getWorkflow(cmd.id); - if (!workflow) { - return asCommandOutcome({ - status: "error", - headline: `Workflow "${cmd.id}" was not found.`, - }); - } - if (workflow.steps.length === 0) { - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" is empty.`, - details: "Add function or agent steps before running.", - data: workflow, - }); - } - return asCommandOutcome({ - headline: `Workflow "${cmd.id}" is ready.`, - details: - "Running from the copilot will be available once the runtime bridge is connected.", - list: workflow.steps.map((step, index) => `${index + 1}. ${step.type} → ${step.id}`), - data: workflow, - }); - } - case "unknown": - return asCommandOutcome({ - status: "error", - headline: "I need more detail before taking action.", - details: cmd.clarification ?? "Try rephrasing or be more specific about the workflow, agent, or MCP server.", - }); - } -} diff --git a/apps/cli/src/application/assistant/mcp/service.ts b/apps/cli/src/application/assistant/mcp/service.ts deleted file mode 100644 index 44014ec7..00000000 --- a/apps/cli/src/application/assistant/mcp/service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { z } from "zod"; -import { McpServerConfig } from "../../entities/mcp.js"; -import { WorkDir } from "../../config/config.js"; - -export function mcpConfigPath(): string { - return path.join(WorkDir, "mcp", "servers.json"); -} - -export function readMcpConfig(): z.infer { - const p = mcpConfigPath(); - if (!fs.existsSync(p)) return { mcpServers: {} }; - const raw = fs.readFileSync(p, "utf8"); - return McpServerConfig.parse(JSON.parse(raw)); -} - -export function writeMcpConfig(value: z.infer): void { - const p = mcpConfigPath(); - const parsed = McpServerConfig.parse(value); - fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8"); -} diff --git a/apps/cli/src/application/assistant/services/storage.ts b/apps/cli/src/application/assistant/services/storage.ts deleted file mode 100644 index 4ec0f2c9..00000000 --- a/apps/cli/src/application/assistant/services/storage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { WorkDir } from "../../config/config.js"; - -export type DirKind = "workflows" | "agents" | "mcp"; - -export function dirFor(kind: DirKind): string { - switch (kind) { - case "workflows": - return path.join(WorkDir, "workflows"); - case "agents": - return path.join(WorkDir, "agents"); - case "mcp": - return path.join(WorkDir, "mcp"); - } -} - -export function listJson(kind: DirKind): string[] { - const d = dirFor(kind); - if (!fs.existsSync(d)) return []; - return fs - .readdirSync(d) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")); -} - -export function readJson(kind: DirKind, id: string): T | undefined { - const p = path.join(dirFor(kind), `${id}.json`); - if (!fs.existsSync(p)) return undefined; - const raw = fs.readFileSync(p, "utf8"); - return JSON.parse(raw) as T; -} - -export function writeJson(kind: DirKind, id: string, value: unknown): void { - const p = path.join(dirFor(kind), `${id}.json`); - fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8"); -} - -export function deleteJson(kind: DirKind, id: string): boolean { - const p = path.join(dirFor(kind), `${id}.json`); - if (!fs.existsSync(p)) return false; - fs.rmSync(p); - return true; -} diff --git a/apps/cli/src/application/assistant/workflows/service.ts b/apps/cli/src/application/assistant/workflows/service.ts deleted file mode 100644 index d01d0796..00000000 --- a/apps/cli/src/application/assistant/workflows/service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from "zod"; -import { Workflow } from "../../entities/workflow.js"; -import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js"; - -export type WorkflowId = string; - -export function listWorkflows(): WorkflowId[] { - return listJson("workflows"); -} - -export function getWorkflow(id: WorkflowId): z.infer | undefined { - const raw = readJson("workflows", id); - if (!raw) return undefined; - return Workflow.parse(raw); -} - -export function upsertWorkflow( - id: WorkflowId, - value: Partial> -): z.infer { - const existing = readJson("workflows", id) as Partial> | undefined; - const now = new Date().toISOString(); - - const defaults: Partial> = { - name: id, - description: "", - steps: [], - createdAt: existing?.createdAt ?? now, - }; - const merged = { - ...defaults, - ...(existing ?? {}), - ...value, - updatedAt: now, - } satisfies Partial>; - - const parsed = Workflow.parse(merged); - writeJson("workflows", id, parsed); - return parsed; -} - -export function deleteWorkflow(id: WorkflowId): boolean { - return deleteJson("workflows", id); -} diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts index 9dbd8edb..31c0834d 100644 --- a/apps/cli/src/x.ts +++ b/apps/cli/src/x.ts @@ -6,3 +6,6 @@ export const start = () => { process.exitCode = 1; }); } + +// Run the copilot when this file is executed directly +start(); From 432ec0ee904f0db16e73bac1c480d5e248afd6ba Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 13 Nov 2025 13:25:04 +0530 Subject: [PATCH 14/38] fix: base dir in copilot --- apps/cli/src/application/assistant/chat.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 12462b5d..7f93851c 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -5,12 +5,13 @@ import { stdin as input, stdout as output } from "process"; import { z } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; +import * as os from "os"; const model = openai("gpt-4.1"); const rl = readline.createInterface({ input, output }); -// Base directory for file operations -const BASE_DIR = "/Users/tusharmagar/.rowboat"; +// Base directory for file operations - dynamically use user's home directory +const BASE_DIR = path.join(os.homedir(), ".rowboat"); // Ensure base directory exists async function ensureBaseDir() { From 62caa0c8b6d986ea07262eed56e803d177f4ace3 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 13 Nov 2025 15:47:33 +0530 Subject: [PATCH 15/38] feature: added ability to search for MCP tools and prompt to add it to the workflow without error --- apps/cli/src/application/assistant/chat.ts | 235 ++++++++++++++++++++- apps/cli/src/x.ts | 3 - 2 files changed, 233 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 7f93851c..544c5c17 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -6,6 +6,10 @@ import { z } from "zod"; import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; const model = openai("gpt-4.1"); const rl = readline.createInterface({ input, output }); @@ -69,8 +73,107 @@ WORKFLOW KNOWLEDGE: - Workflows are JSON files that orchestrate multiple agents - Agents are JSON files defining AI assistants with specific tools and instructions - Tools can be built-in functions or MCP (Model Context Protocol) integrations -- Common structure for workflows: { "name": "workflow_name", "description": "...", "steps": [{"type": "agent", "id": "agent_id"}, ...] } -- Common structure for agents: { "name": "agent_name", "description": "...", "model": "gpt-4o", "instructions": "...", "tools": {...} } + +NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files + +CORRECT WORKFLOW FORMAT: +{ + "name": "workflow_name", // REQUIRED - must match filename + "description": "Description...", // REQUIRED - must be a description of the workflow + "steps": [ // REQUIRED - array of steps + { + "type": "agent", // REQUIRED - always "agent" + "id": "agent_name" // REQUIRED - must match agent filename + }, + { + "type": "agent", + "id": "another_agent_name" + } + ] +} + +CORRECT AGENT FORMAT (with detailed tool structure): +{ + "name": "agent_name", // REQUIRED - must match filename + "description": "What agent does", // REQUIRED - must be a description of the agent + "model": "gpt-4.1", // REQUIRED - model to use + "instructions": "Instructions...", // REQUIRED - agent instructions + "tools": { // OPTIONAL - can be empty {} or omitted + "descriptive_tool_name": { + "type": "mcp", // REQUIRED - always "mcp" for MCP tools + "name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server + "description": "What tool does", // REQUIRED - clear description + "mcpServerName": "server_name", // REQUIRED - name from mcp.json config + "inputSchema": { // REQUIRED - full JSON schema + "type": "object", + "properties": { + "param1": { + "type": "string", + "description": "Description of param" // description is optional but helpful + } + }, + "required": ["param1"] // OPTIONAL - only include if params are required + } + } + } +} + +IMPORTANT NOTES: +- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED) +- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name +- Agents can have empty tools {} if they don't need external tools +- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters +- If all parameters are optional, you can omit the "required" field entirely +- Property descriptions in inputSchema are optional but helpful for clarity +- All other fields marked REQUIRED must always be present + +EXAMPLE 1 - Firecrawl Search Tool (with required params): +{ + "tools": { + "search": { + "type": "mcp", + "name": "firecrawl_search", + "description": "Search the web", + "mcpServerName": "firecrawl", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "limit": {"type": "number", "description": "Number of results"}, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["web", "images", "news"]} + }, + "required": ["type"] + } + } + }, + "required": ["query"] + } + } + } +} + +EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array): +{ + "tools": { + "text_to_speech": { + "type": "mcp", + "name": "text_to_speech", + "description": "Generate audio from text", + "mcpServerName": "elevenLabs", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"} + } + } + } + } +} CRITICAL NAMING AND ORGANIZATION RULES: - Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") @@ -88,6 +191,16 @@ YOUR CAPABILITIES: 3. Update existing files intelligently 4. Read and analyze file contents to maintain consistency 5. Suggest improvements and ask clarifying questions when needed +6. List and explore MCP (Model Context Protocol) servers and their available tools + - Use listMcpServers to see all configured MCP servers + - Use listMcpTools to see what tools are available in a specific MCP server + - This helps users understand what external integrations they can use in their workflows + +MCP INTEGRATION: +- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs) +- MCP configuration is stored in config/mcp.json +- When users ask about available integrations or tools, check MCP servers +- Help users understand which MCP tools they can add to their agents DELETION RULES: - When a user asks to delete a WORKFLOW, you MUST: @@ -367,6 +480,124 @@ Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, } }, }), + + listMcpServers: tool({ + description: 'List all available MCP servers from the configuration', + inputSchema: z.object({}), + execute: async () => { + try { + const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); + + // Check if config exists + try { + await fs.access(configPath); + } catch { + return { + success: true, + servers: [], + message: 'No MCP servers configured yet', + }; + } + + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + const servers = Object.keys(config.mcpServers || {}).map(name => { + const server = config.mcpServers[name]; + return { + name, + type: 'command' in server ? 'stdio' : 'http', + command: server.command, + url: server.url, + }; + }); + + return { + success: true, + servers, + count: servers.length, + message: `Found ${servers.length} MCP server(s)`, + }; + } catch (error) { + return { + success: false, + message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), + + listMcpTools: tool({ + description: 'List all available tools from a specific MCP server', + inputSchema: z.object({ + serverName: z.string().describe('Name of the MCP server to query'), + }), + execute: async ({ serverName }) => { + try { + const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + const mcpConfig = config.mcpServers[serverName]; + if (!mcpConfig) { + return { + success: false, + message: `MCP server '${serverName}' not found in configuration`, + }; + } + + // Create transport based on config type + let transport; + if ('command' in mcpConfig) { + transport = new StdioClientTransport({ + command: mcpConfig.command, + args: mcpConfig.args || [], + env: mcpConfig.env || {}, + }); + } else { + try { + transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url)); + } catch { + transport = new SSEClientTransport(new URL(mcpConfig.url)); + } + } + + // Create and connect client + const client = new Client({ + name: 'rowboat-copilot', + version: '1.0.0', + }); + + await client.connect(transport); + + // List available tools + const toolsList = await client.listTools(); + + // Close connection + client.close(); + transport.close(); + + const tools = toolsList.tools.map((t: any) => ({ + name: t.name, + description: t.description || 'No description', + inputSchema: t.inputSchema, + })); + + return { + success: true, + serverName, + tools, + count: tools.length, + message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`, + }; + } catch (error) { + return { + success: false, + message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }), }, stopWhen: stepCountIs(15), diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts index 31c0834d..9dbd8edb 100644 --- a/apps/cli/src/x.ts +++ b/apps/cli/src/x.ts @@ -6,6 +6,3 @@ export const start = () => { process.exitCode = 1; }); } - -// Run the copilot when this file is executed directly -start(); From 6251c8f0078c1606dd28f2b68f56640ffa436a7e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:13:28 +0530 Subject: [PATCH 16/38] allow provider / model config --- apps/cli/package-lock.json | 34 ++++++++++++++++++ apps/cli/package.json | 1 + apps/cli/src/application/assistant/chat.ts | 7 ++-- apps/cli/src/application/config/config.ts | 36 +++++++++++++++++-- apps/cli/src/application/entities/agent.ts | 3 +- apps/cli/src/application/entities/models.ts | 15 ++++++++ apps/cli/src/application/lib/agent.ts | 11 +++--- apps/cli/src/application/lib/models.ts | 40 +++++++++++++++++++++ apps/cli/src/test.ts | 5 +++ 9 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/application/entities/models.ts create mode 100644 apps/cli/src/application/lib/models.ts create mode 100644 apps/cli/src/test.ts diff --git a/apps/cli/package-lock.json b/apps/cli/package-lock.json index 28c676f5..d276637b 100644 --- a/apps/cli/package-lock.json +++ b/apps/cli/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { + "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", "@modelcontextprotocol/sdk": "^1.20.2", @@ -26,6 +27,39 @@ "typescript": "^5.9.3" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.44.tgz", + "integrity": "sha512-o8TfNXRzO/KZkBrcx+CL9LQsPhx7PHyqzUGjza3TJaF9WxfH1S5UQLAmEw8F7lQoHNLU0IX03WT8o8R/4JbUxQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.17" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.17.tgz", + "integrity": "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/gateway": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", diff --git a/apps/cli/package.json b/apps/cli/package.json index dfd4c3bb..fbcf5b66 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -25,6 +25,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", "@modelcontextprotocol/sdk": "^1.20.2", diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 544c5c17..15969d29 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -1,5 +1,4 @@ import { streamText, ModelMessage, tool, stepCountIs } from "ai"; -import { openai } from "@ai-sdk/openai"; import * as readline from "readline/promises"; import { stdin as input, stdout as output } from "process"; import { z } from "zod"; @@ -10,8 +9,9 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { getProvider } from "../lib/models.js"; +import { DefaultModel } from "../config/config.js"; -const model = openai("gpt-4.1"); const rl = readline.createInterface({ input, output }); // Base directory for file operations - dynamically use user's home directory @@ -57,8 +57,9 @@ export async function startCopilot() { process.stdout.write("\nCopilot: "); let currentStep = 0; + const provider = getProvider(); const result = streamText({ - model: model, + model: provider(DefaultModel), messages: messages, system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 8ee404b9..12533ce3 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -1,13 +1,14 @@ import path from "path"; import fs from "fs"; import { McpServerConfig } from "../entities/mcp.js"; +import { ModelConfig } from "../entities/models.js"; import { z } from "zod"; import { homedir } from "os"; // Resolve app root relative to compiled file location (dist/...) export const WorkDir = path.join(homedir(), ".rowboat"); -const baseMcpConfig = { +const baseMcpConfig: z.infer = { mcpServers: { firecrawl: { command: "npx", @@ -23,7 +24,19 @@ const baseMcpConfig = { }, }, } -} +}; + +const baseModelConfig: z.infer = { + providers: { + openai: { + flavor: "openai", + }, + }, + defaults: { + provider: "openai", + model: "gpt-4.1", + } +}; function ensureMcpConfig() { const configPath = path.join(WorkDir, "config", "mcp.json"); @@ -32,6 +45,13 @@ function ensureMcpConfig() { } } +function ensureModelConfig() { + const configPath = path.join(WorkDir, "config", "models.json"); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify(baseModelConfig, null, 2)); + } +} + function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; ensure(WorkDir); @@ -39,6 +59,7 @@ function ensureDirs() { ensure(path.join(WorkDir, "agents")); ensure(path.join(WorkDir, "config")); ensureMcpConfig(); + ensureModelConfig(); } ensureDirs(); @@ -50,5 +71,16 @@ function loadMcpServerConfig(): z.infer { return McpServerConfig.parse(JSON.parse(config)); } +function loadModelConfig(): z.infer { + const configPath = path.join(WorkDir, "config", "models.json"); + if (!fs.existsSync(configPath)) return baseModelConfig; + const config = fs.readFileSync(configPath, "utf8"); + return ModelConfig.parse(JSON.parse(config)); +} + const { mcpServers } = loadMcpServerConfig(); +const { providers, defaults } = loadModelConfig(); export const McpServers = mcpServers; +export const Providers = providers; +export const DefaultModel = defaults.model; +export const DefaultProvider = defaults.provider; diff --git a/apps/cli/src/application/entities/agent.ts b/apps/cli/src/application/entities/agent.ts index adea0505..e2cd52a4 100644 --- a/apps/cli/src/application/entities/agent.ts +++ b/apps/cli/src/application/entities/agent.ts @@ -27,7 +27,8 @@ export const AgentTool = z.discriminatedUnion("type", [ export const Agent = z.object({ name: z.string(), - model: z.string(), + provider: z.string().optional(), + model: z.string().optional(), description: z.string(), instructions: z.string(), tools: z.record(z.string(), AgentTool).optional(), diff --git a/apps/cli/src/application/entities/models.ts b/apps/cli/src/application/entities/models.ts new file mode 100644 index 00000000..69c7e573 --- /dev/null +++ b/apps/cli/src/application/entities/models.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +export const Provider = z.object({ + flavor: z.enum(["openai", "anthropic", "google"]), + apiKey: z.string().optional(), + baseURL: z.string().optional(), +}); + +export const ModelConfig = z.object({ + providers: z.record(z.string(), Provider), + defaults: z.object({ + provider: z.string(), + model: z.string(), + }), +}); \ No newline at end of file diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 4f858b93..7a1d09a9 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -1,14 +1,13 @@ import { Message, MessageList } from "../entities/message.js"; import { z } from "zod"; import { Step, StepInputT, StepOutputT } from "./step.js"; -import { openai } from "@ai-sdk/openai"; -import { google } from "@ai-sdk/google"; -import { generateText, ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai"; +import { ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai"; import { Agent, AgentTool } from "../entities/agent.js"; -import { WorkDir } from "../config/config.js"; +import { DefaultModel, WorkDir } from "../config/config.js"; import fs from "fs"; import path from "path"; import { loadWorkflow } from "./utils.js"; +import { getProvider } from "./models.js"; const BashTool = tool({ description: "Run a command in the shell", @@ -157,9 +156,9 @@ export class AgentNode implements Step { // console.log("\n\n\t>>>>\t\ttools", JSON.stringify(tools, null, 2)); + const provider = getProvider(this.agent.provider); const { fullStream } = streamText({ - model: openai("gpt-4.1"), - // model: google("gemini-2.5-flash"), + model: provider(this.agent.model || DefaultModel), messages: convertFromMessages(input), system: this.agent.instructions, stopWhen: stepCountIs(1), diff --git a/apps/cli/src/application/lib/models.ts b/apps/cli/src/application/lib/models.ts new file mode 100644 index 00000000..74a1b36d --- /dev/null +++ b/apps/cli/src/application/lib/models.ts @@ -0,0 +1,40 @@ +import { createOpenAI, OpenAIProvider } from "@ai-sdk/openai"; +import { createGoogleGenerativeAI, GoogleGenerativeAIProvider } from "@ai-sdk/google"; +import { AnthropicProvider, createAnthropic } from "@ai-sdk/anthropic"; +import { DefaultModel, DefaultProvider, Providers } from "../config/config.js"; + +const providerMap: Record = {}; + +export function getProvider(name: string = "") { + if (!name) { + name = DefaultProvider; + } + if (providerMap[name]) { + return providerMap[name]; + } + const providerConfig = Providers[name]; + if (!providerConfig) { + throw new Error(`Provider ${name} not found`); + } + switch (providerConfig.flavor) { + case "openai": + providerMap[name] = createOpenAI({ + apiKey: providerConfig.apiKey, + baseURL: providerConfig.baseURL, + }); + break; + case "anthropic": + providerMap[name] = createAnthropic({ + apiKey: providerConfig.apiKey, + baseURL: providerConfig.baseURL, + }); + break; + case "google": + providerMap[name] = createGoogleGenerativeAI({ + apiKey: providerConfig.apiKey, + baseURL: providerConfig.baseURL, + }); + break; + } + return providerMap[name]; +} \ No newline at end of file diff --git a/apps/cli/src/test.ts b/apps/cli/src/test.ts new file mode 100644 index 00000000..d88509de --- /dev/null +++ b/apps/cli/src/test.ts @@ -0,0 +1,5 @@ +import { RunEvent } from "./application/entities/workflow-event.js"; + +const obj = {"type":"tool-invocation","stepId":"test_agent","toolName":"ask-human","input":{"question":"Do you want me to run the command `date` in the terminal to show today’s date?"},"ts":"2025-11-11T06:31:20.103Z"}; + +console.log(RunEvent.parse(obj)); \ No newline at end of file From 61924d0b01c0700f9c3c0b12092b3bc9ea133198 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:13:58 +0530 Subject: [PATCH 17/38] remove test.ts --- apps/cli/src/test.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 apps/cli/src/test.ts diff --git a/apps/cli/src/test.ts b/apps/cli/src/test.ts deleted file mode 100644 index d88509de..00000000 --- a/apps/cli/src/test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RunEvent } from "./application/entities/workflow-event.js"; - -const obj = {"type":"tool-invocation","stepId":"test_agent","toolName":"ask-human","input":{"question":"Do you want me to run the command `date` in the terminal to show today’s date?"},"ts":"2025-11-11T06:31:20.103Z"}; - -console.log(RunEvent.parse(obj)); \ No newline at end of file From fb355ec10d1b540031d509812fa365268ba0dd85 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:23:37 +0530 Subject: [PATCH 18/38] refactor model / provider code --- apps/cli/src/application/assistant/chat.ts | 4 ++-- apps/cli/src/application/config/config.ts | 13 +++++-------- apps/cli/src/application/lib/agent.ts | 4 ++-- apps/cli/src/application/lib/models.ts | 6 +++--- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 15969d29..bc1a77c3 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -10,7 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { getProvider } from "../lib/models.js"; -import { DefaultModel } from "../config/config.js"; +import { ModelConfig } from "../config/config.js"; const rl = readline.createInterface({ input, output }); @@ -59,7 +59,7 @@ export async function startCopilot() { let currentStep = 0; const provider = getProvider(); const result = streamText({ - model: provider(DefaultModel), + model: provider(ModelConfig.defaults.model), messages: messages, system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 12533ce3..f28a03fb 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -1,7 +1,7 @@ import path from "path"; import fs from "fs"; import { McpServerConfig } from "../entities/mcp.js"; -import { ModelConfig } from "../entities/models.js"; +import { ModelConfig as ModelConfigT } from "../entities/models.js"; import { z } from "zod"; import { homedir } from "os"; @@ -26,7 +26,7 @@ const baseMcpConfig: z.infer = { } }; -const baseModelConfig: z.infer = { +const baseModelConfig: z.infer = { providers: { openai: { flavor: "openai", @@ -71,16 +71,13 @@ function loadMcpServerConfig(): z.infer { return McpServerConfig.parse(JSON.parse(config)); } -function loadModelConfig(): z.infer { +function loadModelConfig(): z.infer { const configPath = path.join(WorkDir, "config", "models.json"); if (!fs.existsSync(configPath)) return baseModelConfig; const config = fs.readFileSync(configPath, "utf8"); - return ModelConfig.parse(JSON.parse(config)); + return ModelConfigT.parse(JSON.parse(config)); } const { mcpServers } = loadMcpServerConfig(); -const { providers, defaults } = loadModelConfig(); export const McpServers = mcpServers; -export const Providers = providers; -export const DefaultModel = defaults.model; -export const DefaultProvider = defaults.provider; +export const ModelConfig = loadModelConfig(); diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 7a1d09a9..e43d2427 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { Step, StepInputT, StepOutputT } from "./step.js"; import { ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai"; import { Agent, AgentTool } from "../entities/agent.js"; -import { DefaultModel, WorkDir } from "../config/config.js"; +import { ModelConfig, WorkDir } from "../config/config.js"; import fs from "fs"; import path from "path"; import { loadWorkflow } from "./utils.js"; @@ -158,7 +158,7 @@ export class AgentNode implements Step { const provider = getProvider(this.agent.provider); const { fullStream } = streamText({ - model: provider(this.agent.model || DefaultModel), + model: provider(this.agent.model || ModelConfig.defaults.model), messages: convertFromMessages(input), system: this.agent.instructions, stopWhen: stepCountIs(1), diff --git a/apps/cli/src/application/lib/models.ts b/apps/cli/src/application/lib/models.ts index 74a1b36d..de35dba3 100644 --- a/apps/cli/src/application/lib/models.ts +++ b/apps/cli/src/application/lib/models.ts @@ -1,18 +1,18 @@ import { createOpenAI, OpenAIProvider } from "@ai-sdk/openai"; import { createGoogleGenerativeAI, GoogleGenerativeAIProvider } from "@ai-sdk/google"; import { AnthropicProvider, createAnthropic } from "@ai-sdk/anthropic"; -import { DefaultModel, DefaultProvider, Providers } from "../config/config.js"; +import { ModelConfig } from "../config/config.js"; const providerMap: Record = {}; export function getProvider(name: string = "") { if (!name) { - name = DefaultProvider; + name = ModelConfig.defaults.provider; } if (providerMap[name]) { return providerMap[name]; } - const providerConfig = Providers[name]; + const providerConfig = ModelConfig.providers[name]; if (!providerConfig) { throw new Error(`Provider ${name} not found`); } From d30d551d1634558c85cdd2836aaf681cee52a8ca Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Fri, 14 Nov 2025 11:08:26 +0530 Subject: [PATCH 19/38] feat: enhance streaming response handling with new renderer. Process fullStream instead of textStream only --- apps/cli/src/application/assistant/chat.ts | 138 ++++++++++++++------- 1 file changed, 91 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 544c5c17..6de75ed9 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -10,8 +10,9 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamRenderer } from "../lib/stream-renderer.js"; -const model = openai("gpt-4.1"); +const model = openai("gpt-5"); const rl = readline.createInterface({ input, output }); // Base directory for file operations - dynamically use user's home directory @@ -54,21 +55,11 @@ export async function startCopilot() { messages.push({ role: "user", content: userInput }); // Stream AI response - process.stdout.write("\nCopilot: "); - - let currentStep = 0; const result = streamText({ model: model, messages: messages, system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. -REASONING & THINKING: -- Before taking action, think through what the user is asking for and put out a text with your reasoning process and the steps you will take to complete the task. -- Break down complex tasks into clear steps -- Explore existing files/structure before creating new ones -- Explain your reasoning as you work through tasks -- Be proactive in understanding context - WORKFLOW KNOWLEDGE: - Workflows are JSON files that orchestrate multiple agents - Agents are JSON files defining AI assistants with specific tools and instructions @@ -217,11 +208,11 @@ DELETION RULES: 5. Only delete/modify what the user confirms COMMUNICATION STYLE: -- Start by thinking through the request -- Explain what you're exploring and why -- Show your reasoning process +- Break down complex tasks into clear steps +- Explore existing files/structure before creating new ones +- Explain your reasoning as you work through tasks +- Be proactive in understanding context - Confirm what you've done and suggest next steps -- Be conversational but informative - Always ask for confirmation before destructive operations!! Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, @@ -599,42 +590,95 @@ Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, }, }), }, - - stopWhen: stepCountIs(15), - - onStepFinish: async ({ toolResults }) => { - currentStep++; - - // Show results with clear formatting - if (toolResults && toolResults.length > 0) { - console.log(`\n[Step ${currentStep}]`); - for (const result of toolResults) { - const res = result as any; - console.log(`🔧 Tool: ${res.toolName}`); - - if (res.result && typeof res.result === 'object') { - const resultData = res.result as any; - if (resultData.success) { - console.log(`✅ ${resultData.message || 'Success'}`); - if (resultData.description) console.log(` → ${resultData.description}`); - if (resultData.reason) console.log(` → ${resultData.reason}`); - } else { - console.log(`❌ ${resultData.message || 'Failed'}`); - } - } - } - console.log(); - } - }, + stopWhen: stepCountIs(20), }); - // Stream and collect response + // Initialize renderer with workflow-style output + const renderer = new StreamRenderer({ + showHeaders: false, + dimReasoning: true, + jsonIndent: 2, + truncateJsonAt: 500, + }); + + // Stream and collect response using fullStream let assistantResponse = ""; - for await (const textPart of result.textStream) { - process.stdout.write(textPart); - assistantResponse += textPart; + const { fullStream } = result; + + for await (const event of fullStream) { + switch (event.type) { + case "reasoning-start": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "reasoning-start" } + }); + break; + case "reasoning-delta": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "reasoning-delta", delta: event.text } + }); + break; + case "reasoning-end": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "reasoning-end" } + }); + break; + case "text-start": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "text-start" } + }); + break; + case "text-delta": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "text-delta", delta: event.text } + }); + assistantResponse += event.text; + break; + case "text-end": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { type: "text-end" } + }); + break; + case "tool-call": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: 'args' in event ? event.args : event.input + } + }); + break; + case "tool-result": + // Tool results are not directly rendered in copilot mode + break; + case "finish": + renderer.render({ + type: "stream-event", + stepId: "copilot", + event: { + type: "usage", + usage: event.totalUsage + } + }); + break; + } } - console.log("\n"); + + console.log(); // Add assistant response to history messages.push({ role: "assistant", content: assistantResponse }); From bfd8b0dad3bf00f85599e231018ce9843cb2f2f6 Mon Sep 17 00:00:00 2001 From: Arjun Date: Fri, 14 Nov 2025 14:20:15 +0530 Subject: [PATCH 20/38] added podcast example --- apps/cli/examples/notebooklm-podcast.txt | 128 +++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/cli/examples/notebooklm-podcast.txt diff --git a/apps/cli/examples/notebooklm-podcast.txt b/apps/cli/examples/notebooklm-podcast.txt new file mode 100644 index 00000000..9b957736 --- /dev/null +++ b/apps/cli/examples/notebooklm-podcast.txt @@ -0,0 +1,128 @@ +{ + "name": "podcast", + "description": "A workflow to create a podcast", + "steps": [ + { + "type": "agent", + "id": "arxiv-feed-reader" + }, + { + "type": "agent", + "id": "summarise-a-few" + }, + { + "type": "agent", + "id": "podcast_transcript_agent" + }, + { + "type": "agent", + "id": "elevenlabs_audio_gen" + } + ] +} + +{ + "name": "summariser_workflow", + "description": "A workflow to summarise an arxiv paper", + "steps": [ + { + "type": "agent", + "id": "summariser_agent" + } + ] +} + +{ + "name": "summariser_agent", + "description": "An agent that will summarise an arxiv paper", + "model": "gpt-4.1", + "instructions": "Your job is to download and summarise an arxiv paper. Use a command like this to do it\n\n curl -L -o paper.pdf https://arxiv.org/pdf/2511.02997 (use the url that the user provides you). Important, just put out the GIST of the paper in two lines. Dont ask a human for inputs - do what you think is best.", + "tools": { + "bash": { + "type": "builtin", + "name": "bash" + } + } +} + +{ + "name": "arxiv-feed-reader", + "description": "A feed reader for the arXiv", + "model": "gpt-4.1", + "instructions": "Your job is to extract the latest papers from the arXiv feed and summarise them. Use an example curl command like the following to get this done:\n\n! curl -s https://rss.arxiv.org/rss/cs.AI \\\n| yq -p=xml -o=json \\\n| jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"' \n\nThis will give you a list of papers that contain the word \"agent\" in the title. You can then summarise these papers using the summariser agent.", + "tools": { + "bash": { + "type": "builtin", + "name": "bash" + } + } +} + +{ + "name": "summarise-a-few", + "description": "An agent that will summarise a few arxiv papers", + "model": "gpt-4.1", + "instructions": "Your job is to pick 2 interesting papers and related papers on the same topic, and then summarise each of them inidivually using the right tool calls. Make sure to pass in the URL of the paper to the summaurse tool. Don't ask for human input.", + "tools": { + "summariser": { + "type": "workflow", + "name": "summariser_workflow" + } + } +} + +{ + "name": "podcast_transcript_agent", + "description": "An agent that will generate a transcript of a podcast", + "model": "gpt-4.1", + "instructions": "You job is to create a NotebookLM style 1 minute podcast between 2 speakers John and Chloe. Each line should be a new speaker. The podcast should be about the contents of the two papers (that were selected). You can use [sighs], [inhales then exhales], [chuckles], [laughs], [clears throat], [coughs], [sniffs], [pauses] etc. to make the podcast more natural." +} + +{ + "name": "elevenlabs_audio_gen", + "description": "An agent that will generate an audio file from a text", + "model": "gpt-4.1", + "instructions": "Your job is to take the mutli speaker transcript and generate an audio file from it. Use the elevenlabs text to speech tool to do this. For each speaker turn, you should generate an audio file and then combine them all into a single audio file. Use the voice_name 'Liam' for John and 'Cassidy' for Chloe. Make sure to remove the speaker names from the text before generating the audio files. Use the bash tool to look for the generated audio files and also combine the audio files into a single final podcast audio file. Use 'eleven_v3' for the model_id.", + "tools": { + "text_to_speech": { + "type": "mcp", + "name": "text_to_speech", + "description": "Generate an audio file from a text", + "mcpServerName": "elevenLabs", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to generate an audio file from" + }, + "voice_name": { + "type": "string", + "description": "The voice name to use for the audio file" + }, + "model_id": { + "type": "string", + "description": "The model id to use for the audio file" + } + } + } + }, + "bash": { + "type": "builtin", + "name": "bash" + } + } +} + +{ + "mcpServers": { + + "elevenLabs": { + "command": "uvx", + "args": ["elevenlabs-mcp"], + "env": { + "ELEVENLABS_API_KEY": "sk_42ee2a0a19266552c18b0920b593e22f0185d4b1435b65ed" + } + } + } +} \ No newline at end of file From 164eefbbfdb63c037dcbe95d68408bbf25fb99fb Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Fri, 14 Nov 2025 14:41:04 +0530 Subject: [PATCH 21/38] feat: add shell command execution capability to assistant chat. Users can now run bash commands and retrieve output, enhancing functionality for system operations. --- apps/cli/src/application/assistant/chat.ts | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts index 0c9a5bbb..837c5291 100644 --- a/apps/cli/src/application/assistant/chat.ts +++ b/apps/cli/src/application/assistant/chat.ts @@ -12,6 +12,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamRenderer } from "../lib/stream-renderer.js"; import { getProvider } from "../lib/models.js"; import { ModelConfig } from "../config/config.js"; +import { executeCommand } from "../lib/command-executor.js"; const rl = readline.createInterface({ input, output }); @@ -186,7 +187,11 @@ YOUR CAPABILITIES: 3. Update existing files intelligently 4. Read and analyze file contents to maintain consistency 5. Suggest improvements and ask clarifying questions when needed -6. List and explore MCP (Model Context Protocol) servers and their available tools +6. Execute shell commands to perform system operations + - Use executeCommand to run bash/shell commands + - Can list files, check system info, run scripts, etc. + - Commands execute in the .rowboat directory by default +7. List and explore MCP (Model Context Protocol) servers and their available tools - Use listMcpServers to see all configured MCP servers - Use listMcpTools to see what tools are available in a specific MCP server - This helps users understand what external integrations they can use in their workflows @@ -593,6 +598,35 @@ Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, } }, }), + + executeCommand: tool({ + description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', + inputSchema: z.object({ + command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), + cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'), + }), + execute: async ({ command, cwd }) => { + try { + const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR; + const result = await executeCommand(command, { cwd: workingDir }); + + return { + success: result.exitCode === 0, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + command, + workingDir, + }; + } catch (error) { + return { + success: false, + message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`, + command, + }; + } + }, + }), }, stopWhen: stepCountIs(20), }); From 80dae17fd121ab979ffad37adee626e2f12edb9c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:51:22 +0530 Subject: [PATCH 22/38] everything is an agent --- apps/cli/bin/app.js | 32 +- ...lm-podcast.txt => notebooklm-podcast.json} | 0 apps/cli/package-lock.json | 159 ++++ apps/cli/package.json | 1 + apps/cli/src/app.ts | 120 +-- apps/cli/src/application/assistant/agent.ts | 20 + apps/cli/src/application/assistant/chat.ts | 731 ------------------ .../src/application/assistant/instructions.ts | 164 ++++ apps/cli/src/application/config/config.ts | 3 +- apps/cli/src/application/entities/agent.ts | 20 +- .../{llm-step-event.ts => llm-step-events.ts} | 0 .../{workflow-event.ts => run-events.ts} | 16 +- apps/cli/src/application/entities/workflow.ts | 21 - .../cli/src/application/functions/get_date.ts | 22 - apps/cli/src/application/lib/agent.ts | 525 ++++++++++--- apps/cli/src/application/lib/builtin-tools.ts | 424 ++++++++++ apps/cli/src/application/lib/exec-tool.ts | 61 +- apps/cli/src/application/lib/exec-workflow.ts | 449 ----------- apps/cli/src/application/lib/step.ts | 6 +- .../src/application/lib/stream-renderer.ts | 36 +- apps/cli/src/application/lib/utils.ts | 10 - .../cli/src/application/registry/functions.ts | 6 - apps/cli/src/application/registry/tools.ts | 0 apps/cli/src/x.ts | 8 - 24 files changed, 1261 insertions(+), 1573 deletions(-) rename apps/cli/examples/{notebooklm-podcast.txt => notebooklm-podcast.json} (100%) create mode 100644 apps/cli/src/application/assistant/agent.ts delete mode 100644 apps/cli/src/application/assistant/chat.ts create mode 100644 apps/cli/src/application/assistant/instructions.ts rename apps/cli/src/application/entities/{llm-step-event.ts => llm-step-events.ts} (100%) rename apps/cli/src/application/entities/{workflow-event.ts => run-events.ts} (81%) delete mode 100644 apps/cli/src/application/entities/workflow.ts delete mode 100644 apps/cli/src/application/functions/get_date.ts create mode 100644 apps/cli/src/application/lib/builtin-tools.ts delete mode 100644 apps/cli/src/application/lib/exec-workflow.ts delete mode 100644 apps/cli/src/application/lib/utils.ts delete mode 100644 apps/cli/src/application/registry/functions.ts delete mode 100644 apps/cli/src/application/registry/tools.ts delete mode 100644 apps/cli/src/x.ts diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js index af06a71f..172d56ed 100755 --- a/apps/cli/bin/app.js +++ b/apps/cli/bin/app.js @@ -1,4 +1,32 @@ #!/usr/bin/env node +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { app } from '../dist/app.js'; -import { start } from '../dist/x.js'; -start(); \ No newline at end of file +yargs(hideBin(process.argv)) + .command( + "$0", + "Run rowboatx", + (y) => y + .option("agent", { + type: "string", + description: "The agent to run", + default: "copilot", + }) + .option("run_id", { + type: "string", + description: "Continue an existing run", + }) + .option("input", { + type: "string", + description: "The input to the agent", + }), + (argv) => { + app({ + agent: argv.agent, + runId: argv.run_id, + input: argv.input, + }); + } + ) + .parse(); \ No newline at end of file diff --git a/apps/cli/examples/notebooklm-podcast.txt b/apps/cli/examples/notebooklm-podcast.json similarity index 100% rename from apps/cli/examples/notebooklm-podcast.txt rename to apps/cli/examples/notebooklm-podcast.json diff --git a/apps/cli/package-lock.json b/apps/cli/package-lock.json index d276637b..b54512a7 100644 --- a/apps/cli/package-lock.json +++ b/apps/cli/package-lock.json @@ -16,6 +16,7 @@ "ai": "^5.0.78", "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", + "yargs": "^18.0.0", "zod": "^4.1.12" }, "bin": { @@ -372,6 +373,30 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -437,6 +462,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -566,6 +605,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -605,6 +650,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -754,6 +808,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1346,6 +1421,38 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1483,12 +1590,64 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/apps/cli/package.json b/apps/cli/package.json index fbcf5b66..23991bc6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,6 +32,7 @@ "ai": "^5.0.78", "json-schema-to-zod": "^2.6.1", "nanoid": "^5.1.6", + "yargs": "^18.0.0", "zod": "^4.1.12" } } diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 2945956c..061bdcb2 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -1,117 +1,19 @@ -import { executeWorkflow, resumeWorkflow } from "./application/lib/exec-workflow.js"; +import { streamAgent } from "./application/lib/agent.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -type ParsedArgs = { - command: "run" | "resume" | "help" | null; - id: string | null; - interactive: boolean; - message: string; -}; - -function parseArgs(argv: string[]): ParsedArgs { - const args = argv.slice(2); - if (args.length === 0) { - return { command: "help", id: null, interactive: true, message: "" }; - } - - let command: ParsedArgs["command"] = null; - let id: string | null = null; - let interactive = true; - const messageParts: string[] = []; - - if (args[0] !== "run" && args[0] !== "resume") { - command = "help"; - return { command, id: null, interactive, message: "" }; - } - command = args[0]; - - for (let i = 1; i < args.length; i++) { - const a = args[i]; - if (a.startsWith("--")) { - if (a === "--no-interactive") { - interactive = false; - } else if (a.startsWith("--interactive")) { - const [, value] = a.split("="); - if (value === undefined) { - interactive = true; - } else { - interactive = value !== "false"; - } - } - continue; - } - if (!id) { - id = a; - continue; - } - messageParts.push(a); - } - - return { command, id, interactive, message: messageParts.join(" ") }; -} - -function printUsage(): void { - console.log([ - "Usage:", - " rowboatx run [message...] [--interactive | --no-interactive]", - " rowboatx resume [message...] [--interactive | --no-interactive]", - "", - "Flags:", - " --interactive Run interactively (default: true)", - " --no-interactive Disable interactive prompts", - ].join("\n")); -} - -async function promptForResumeInput(): Promise { - const rl = createInterface({ input, output }); - try { - const answer = await rl.question("Enter input to resume the run: "); - return answer; - } finally { - rl.close(); - } -} - -async function render(generator: AsyncGenerator): Promise { +export async function app(opts: { + agent: string; + runId?: string; + input?: string; +}) { const renderer = new StreamRenderer(); - for await (const event of generator) { + for await (const event of streamAgent({ + ...opts, + interactive: true, + })) { renderer.render(event); if (event?.type === "error") { process.exitCode = 1; } } -} - -async function main() { - const { command, id, interactive, message } = parseArgs(process.argv); - - if (command === "help" || !command) { - printUsage(); - return; - } - if (!id) { - printUsage(); - process.exitCode = 1; - return; - } - - switch (command) { - case "run": { - const initialInput = message ?? ""; - await render(executeWorkflow(id, initialInput, interactive)); - break; - } - case "resume": { - const resumeInput = message !== "" ? message : (interactive ? await promptForResumeInput() : ""); - await render(resumeWorkflow(id, resumeInput, interactive)); - break; - } - } -} - -main().catch((err) => { - console.error("Failed:", err instanceof Error ? err.message : String(err)); - process.exitCode = 1; -}); \ No newline at end of file +} \ No newline at end of file diff --git a/apps/cli/src/application/assistant/agent.ts b/apps/cli/src/application/assistant/agent.ts new file mode 100644 index 00000000..d06d85f2 --- /dev/null +++ b/apps/cli/src/application/assistant/agent.ts @@ -0,0 +1,20 @@ +import { Agent, ToolAttachment } from "../entities/agent.js"; +import z from "zod"; +import { CopilotInstructions } from "./instructions.js"; +import { BuiltinTools } from "../lib/builtin-tools.js"; + +const tools: Record> = {}; +for (const [name, tool] of Object.entries(BuiltinTools)) { + tools[name] = { + type: "builtin", + name, + }; +} + +export const CopilotAgent: z.infer = { + name: "rowboatx", + description: "Rowboatx copilot", + instructions: CopilotInstructions, + model: "gpt-4.1", + tools, +} \ No newline at end of file diff --git a/apps/cli/src/application/assistant/chat.ts b/apps/cli/src/application/assistant/chat.ts deleted file mode 100644 index 837c5291..00000000 --- a/apps/cli/src/application/assistant/chat.ts +++ /dev/null @@ -1,731 +0,0 @@ -import { streamText, ModelMessage, tool, stepCountIs } from "ai"; -import * as readline from "readline/promises"; -import { stdin as input, stdout as output } from "process"; -import { z } from "zod"; -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamRenderer } from "../lib/stream-renderer.js"; -import { getProvider } from "../lib/models.js"; -import { ModelConfig } from "../config/config.js"; -import { executeCommand } from "../lib/command-executor.js"; - -const rl = readline.createInterface({ input, output }); - -// Base directory for file operations - dynamically use user's home directory -const BASE_DIR = path.join(os.homedir(), ".rowboat"); - -// Ensure base directory exists -async function ensureBaseDir() { - try { - await fs.access(BASE_DIR); - } catch { - await fs.mkdir(BASE_DIR, { recursive: true }); - console.log(`📁 Created directory: ${BASE_DIR}\n`); - } -} - -// Export the main copilot function -export async function startCopilot() { - // Conversation history - const messages: ModelMessage[] = []; - - console.log("🤖 Rowboat Copilot - Your Intelligent Workflow Assistant"); - console.log(`📂 Working directory: ${BASE_DIR}`); - console.log("💡 I can help you create, manage, and understand workflows."); - console.log("Type 'exit' to quit\n"); - - // Initialize base directory - await ensureBaseDir(); - - while (true) { - // Get user input - const userInput = await rl.question("You: "); - - // Exit condition - if (userInput.toLowerCase() === "exit" || userInput.toLowerCase() === "quit") { - console.log("\n👋 Goodbye!"); - break; - } - - // Add user message to history - messages.push({ role: "user", content: userInput }); - - // Stream AI response - process.stdout.write("\nCopilot: "); - - let currentStep = 0; - const provider = getProvider(); - const result = streamText({ - model: provider(ModelConfig.defaults.model), - messages: messages, - system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. - -WORKFLOW KNOWLEDGE: -- Workflows are JSON files that orchestrate multiple agents -- Agents are JSON files defining AI assistants with specific tools and instructions -- Tools can be built-in functions or MCP (Model Context Protocol) integrations - -NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files - -CORRECT WORKFLOW FORMAT: -{ - "name": "workflow_name", // REQUIRED - must match filename - "description": "Description...", // REQUIRED - must be a description of the workflow - "steps": [ // REQUIRED - array of steps - { - "type": "agent", // REQUIRED - always "agent" - "id": "agent_name" // REQUIRED - must match agent filename - }, - { - "type": "agent", - "id": "another_agent_name" - } - ] -} - -CORRECT AGENT FORMAT (with detailed tool structure): -{ - "name": "agent_name", // REQUIRED - must match filename - "description": "What agent does", // REQUIRED - must be a description of the agent - "model": "gpt-4.1", // REQUIRED - model to use - "instructions": "Instructions...", // REQUIRED - agent instructions - "tools": { // OPTIONAL - can be empty {} or omitted - "descriptive_tool_name": { - "type": "mcp", // REQUIRED - always "mcp" for MCP tools - "name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server - "description": "What tool does", // REQUIRED - clear description - "mcpServerName": "server_name", // REQUIRED - name from mcp.json config - "inputSchema": { // REQUIRED - full JSON schema - "type": "object", - "properties": { - "param1": { - "type": "string", - "description": "Description of param" // description is optional but helpful - } - }, - "required": ["param1"] // OPTIONAL - only include if params are required - } - } - } -} - -IMPORTANT NOTES: -- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED) -- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name -- Agents can have empty tools {} if they don't need external tools -- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters -- If all parameters are optional, you can omit the "required" field entirely -- Property descriptions in inputSchema are optional but helpful for clarity -- All other fields marked REQUIRED must always be present - -EXAMPLE 1 - Firecrawl Search Tool (with required params): -{ - "tools": { - "search": { - "type": "mcp", - "name": "firecrawl_search", - "description": "Search the web", - "mcpServerName": "firecrawl", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "limit": {"type": "number", "description": "Number of results"}, - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": {"type": "string", "enum": ["web", "images", "news"]} - }, - "required": ["type"] - } - } - }, - "required": ["query"] - } - } - } -} - -EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array): -{ - "tools": { - "text_to_speech": { - "type": "mcp", - "name": "text_to_speech", - "description": "Generate audio from text", - "mcpServerName": "elevenLabs", - "inputSchema": { - "type": "object", - "properties": { - "text": {"type": "string"} - } - } - } - } -} - -CRITICAL NAMING AND ORGANIZATION RULES: -- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") -- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json → "name": "workflow_name") -- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"}) -- All three must be identical: filename, JSON "name" field, and workflow step "id" field -- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json) -- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json) -- NEVER create workflows or agents outside these designated folders -- Always maintain this naming and organizational consistency when creating or updating files - -YOUR CAPABILITIES: -1. Explore the directory structure to understand existing workflows/agents -2. Create new workflows and agents following best practices -3. Update existing files intelligently -4. Read and analyze file contents to maintain consistency -5. Suggest improvements and ask clarifying questions when needed -6. Execute shell commands to perform system operations - - Use executeCommand to run bash/shell commands - - Can list files, check system info, run scripts, etc. - - Commands execute in the .rowboat directory by default -7. List and explore MCP (Model Context Protocol) servers and their available tools - - Use listMcpServers to see all configured MCP servers - - Use listMcpTools to see what tools are available in a specific MCP server - - This helps users understand what external integrations they can use in their workflows - -MCP INTEGRATION: -- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs) -- MCP configuration is stored in config/mcp.json -- When users ask about available integrations or tools, check MCP servers -- Help users understand which MCP tools they can add to their agents - -DELETION RULES: -- When a user asks to delete a WORKFLOW, you MUST: - 1. First read/analyze the workflow to identify which agents it uses - 2. List those agents to the user - 3. Ask the user if they want to delete those agents as well - 4. Wait for their response before proceeding with any deletions - 5. Only delete what the user confirms -- When a user asks to delete an AGENT, you MUST: - 1. First read/analyze the agent to identify which workflows it is used in - 2. List those workflows to the user - 3. Ask the user if they want to delete/modify those workflows as well - 4. Wait for their response before proceeding with any deletions - 5. Only delete/modify what the user confirms - -COMMUNICATION STYLE: -- Break down complex tasks into clear steps -- Explore existing files/structure before creating new ones -- Explain your reasoning as you work through tasks -- Be proactive in understanding context -- Confirm what you've done and suggest next steps -- Always ask for confirmation before destructive operations!! - -Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`, - - tools: { - exploreDirectory: tool({ - description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', - inputSchema: z.object({ - subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), - maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), - }), - execute: async ({ subdirectory, maxDepth = 3 }) => { - async function explore(dir: string, depth: number = 0): Promise { - if (depth > maxDepth) return null; - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const result: any = { files: [], directories: {} }; - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isFile()) { - const ext = path.extname(entry.name); - const size = (await fs.stat(fullPath)).size; - result.files.push({ - name: entry.name, - type: ext || 'no-extension', - size: size, - relativePath: path.relative(BASE_DIR, fullPath), - }); - } else if (entry.isDirectory()) { - result.directories[entry.name] = await explore(fullPath, depth + 1); - } - } - - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : 'Unknown error' }; - } - } - - const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; - const structure = await explore(dirPath); - - return { - success: true, - basePath: path.relative(BASE_DIR, dirPath) || '.', - structure, - }; - }, - }), - - readFile: tool({ - description: 'Read and parse file contents. For JSON files, provides parsed structure.', - inputSchema: z.object({ - filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'), - }), - execute: async ({ filename }) => { - try { - const filePath = path.join(BASE_DIR, filename); - const content = await fs.readFile(filePath, 'utf-8'); - - let parsed = null; - let fileType = path.extname(filename); - - if (fileType === '.json') { - try { - parsed = JSON.parse(content); - } catch { - parsed = { error: 'Invalid JSON' }; - } - } - - return { - success: true, - filename, - fileType, - content, - parsed, - path: filePath, - size: content.length, - }; - } catch (error) { - return { - success: false, - message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - createFile: tool({ - description: 'Create a new file with content. Automatically creates parent directories if needed.', - inputSchema: z.object({ - filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'), - content: z.string().describe('The content to write to the file'), - description: z.string().optional().describe('Optional description of why this file is being created'), - }), - execute: async ({ filename, content, description }) => { - try { - const filePath = path.join(BASE_DIR, filename); - const dir = path.dirname(filePath); - - // Ensure directory exists - await fs.mkdir(dir, { recursive: true }); - - // Write file - await fs.writeFile(filePath, content, 'utf-8'); - - return { - success: true, - message: `File '${filename}' created successfully`, - description: description || 'No description provided', - path: filePath, - size: content.length, - }; - } catch (error) { - return { - success: false, - message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - updateFile: tool({ - description: 'Update or overwrite the contents of an existing file', - inputSchema: z.object({ - filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'), - content: z.string().describe('The new content to write to the file'), - reason: z.string().optional().describe('Optional reason for the update'), - }), - execute: async ({ filename, content, reason }) => { - try { - const filePath = path.join(BASE_DIR, filename); - - // Check if file exists - await fs.access(filePath); - - // Update file - await fs.writeFile(filePath, content, 'utf-8'); - - return { - success: true, - message: `File '${filename}' updated successfully`, - reason: reason || 'No reason provided', - path: filePath, - size: content.length, - }; - } catch (error) { - return { - success: false, - message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - deleteFile: tool({ - description: 'Delete a file from the .rowboat directory', - inputSchema: z.object({ - filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'), - }), - execute: async ({ filename }) => { - try { - const filePath = path.join(BASE_DIR, filename); - await fs.unlink(filePath); - - return { - success: true, - message: `File '${filename}' deleted successfully`, - path: filePath, - }; - } catch (error) { - return { - success: false, - message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - listFiles: tool({ - description: 'List all files and directories in the .rowboat directory or subdirectory', - inputSchema: z.object({ - subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'), - }), - execute: async ({ subdirectory }) => { - try { - const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - - const files = entries - .filter(entry => entry.isFile()) - .map(entry => ({ - name: entry.name, - type: path.extname(entry.name) || 'no-extension', - relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)), - })); - - const directories = entries - .filter(entry => entry.isDirectory()) - .map(entry => entry.name); - - return { - success: true, - path: dirPath, - relativePath: path.relative(BASE_DIR, dirPath) || '.', - files, - directories, - totalFiles: files.length, - totalDirectories: directories.length, - }; - } catch (error) { - return { - success: false, - message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - analyzeWorkflow: tool({ - description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies', - inputSchema: z.object({ - workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'), - }), - execute: async ({ workflowName }) => { - try { - const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`; - const filePath = path.join(BASE_DIR, 'workflows', filename); - - const content = await fs.readFile(filePath, 'utf-8'); - const workflow = JSON.parse(content); - - // Extract key information - const analysis = { - name: workflow.name, - description: workflow.description || 'No description', - agentCount: workflow.agents ? workflow.agents.length : 0, - agents: workflow.agents || [], - tools: workflow.tools || {}, - structure: workflow, - }; - - return { - success: true, - filePath: path.relative(BASE_DIR, filePath), - analysis, - }; - } catch (error) { - return { - success: false, - message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - listMcpServers: tool({ - description: 'List all available MCP servers from the configuration', - inputSchema: z.object({}), - execute: async () => { - try { - const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); - - // Check if config exists - try { - await fs.access(configPath); - } catch { - return { - success: true, - servers: [], - message: 'No MCP servers configured yet', - }; - } - - const content = await fs.readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - const servers = Object.keys(config.mcpServers || {}).map(name => { - const server = config.mcpServers[name]; - return { - name, - type: 'command' in server ? 'stdio' : 'http', - command: server.command, - url: server.url, - }; - }); - - return { - success: true, - servers, - count: servers.length, - message: `Found ${servers.length} MCP server(s)`, - }; - } catch (error) { - return { - success: false, - message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - listMcpTools: tool({ - description: 'List all available tools from a specific MCP server', - inputSchema: z.object({ - serverName: z.string().describe('Name of the MCP server to query'), - }), - execute: async ({ serverName }) => { - try { - const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); - const content = await fs.readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - const mcpConfig = config.mcpServers[serverName]; - if (!mcpConfig) { - return { - success: false, - message: `MCP server '${serverName}' not found in configuration`, - }; - } - - // Create transport based on config type - let transport; - if ('command' in mcpConfig) { - transport = new StdioClientTransport({ - command: mcpConfig.command, - args: mcpConfig.args || [], - env: mcpConfig.env || {}, - }); - } else { - try { - transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url)); - } catch { - transport = new SSEClientTransport(new URL(mcpConfig.url)); - } - } - - // Create and connect client - const client = new Client({ - name: 'rowboat-copilot', - version: '1.0.0', - }); - - await client.connect(transport); - - // List available tools - const toolsList = await client.listTools(); - - // Close connection - client.close(); - transport.close(); - - const tools = toolsList.tools.map((t: any) => ({ - name: t.name, - description: t.description || 'No description', - inputSchema: t.inputSchema, - })); - - return { - success: true, - serverName, - tools, - count: tools.length, - message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`, - }; - } catch (error) { - return { - success: false, - message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } - }, - }), - - executeCommand: tool({ - description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', - inputSchema: z.object({ - command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), - cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'), - }), - execute: async ({ command, cwd }) => { - try { - const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR; - const result = await executeCommand(command, { cwd: workingDir }); - - return { - success: result.exitCode === 0, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - command, - workingDir, - }; - } catch (error) { - return { - success: false, - message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`, - command, - }; - } - }, - }), - }, - stopWhen: stepCountIs(20), - }); - - // Initialize renderer with workflow-style output - const renderer = new StreamRenderer({ - showHeaders: false, - dimReasoning: true, - jsonIndent: 2, - truncateJsonAt: 500, - }); - - // Stream and collect response using fullStream - let assistantResponse = ""; - const { fullStream } = result; - - for await (const event of fullStream) { - switch (event.type) { - case "reasoning-start": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "reasoning-start" } - }); - break; - case "reasoning-delta": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "reasoning-delta", delta: event.text } - }); - break; - case "reasoning-end": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "reasoning-end" } - }); - break; - case "text-start": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "text-start" } - }); - break; - case "text-delta": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "text-delta", delta: event.text } - }); - assistantResponse += event.text; - break; - case "text-end": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { type: "text-end" } - }); - break; - case "tool-call": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: 'args' in event ? event.args : event.input - } - }); - break; - case "tool-result": - // Tool results are not directly rendered in copilot mode - break; - case "finish": - renderer.render({ - type: "stream-event", - stepId: "copilot", - event: { - type: "usage", - usage: event.totalUsage - } - }); - break; - } - } - - console.log(); - - // Add assistant response to history - messages.push({ role: "assistant", content: assistantResponse }); - - // Keep only the last 20 messages (10 user + 10 assistant pairs) - if (messages.length > 20) { - messages.splice(0, messages.length - 20); - } - } - - rl.close(); -} diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts new file mode 100644 index 00000000..c7a750b4 --- /dev/null +++ b/apps/cli/src/application/assistant/instructions.ts @@ -0,0 +1,164 @@ +import { WorkDir as BASE_DIR } from "../config/config.js"; + +export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. + +WORKFLOW KNOWLEDGE: +- Workflows are JSON files that orchestrate multiple agents +- Agents are JSON files defining AI assistants with specific tools and instructions +- Tools can be built-in functions or MCP (Model Context Protocol) integrations + +NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files + +CORRECT WORKFLOW FORMAT: +{ + "name": "workflow_name", // REQUIRED - must match filename + "description": "Description...", // REQUIRED - must be a description of the workflow + "steps": [ // REQUIRED - array of steps + { + "type": "agent", // REQUIRED - always "agent" + "id": "agent_name" // REQUIRED - must match agent filename + }, + { + "type": "agent", + "id": "another_agent_name" + } + ] +} + +CORRECT AGENT FORMAT (with detailed tool structure): +{ + "name": "agent_name", // REQUIRED - must match filename + "description": "What agent does", // REQUIRED - must be a description of the agent + "model": "gpt-4.1", // REQUIRED - model to use + "instructions": "Instructions...", // REQUIRED - agent instructions + "tools": { // OPTIONAL - can be empty {} or omitted + "descriptive_tool_name": { + "type": "mcp", // REQUIRED - always "mcp" for MCP tools + "name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server + "description": "What tool does", // REQUIRED - clear description + "mcpServerName": "server_name", // REQUIRED - name from mcp.json config + "inputSchema": { // REQUIRED - full JSON schema + "type": "object", + "properties": { + "param1": { + "type": "string", + "description": "Description of param" // description is optional but helpful + } + }, + "required": ["param1"] // OPTIONAL - only include if params are required + } + } + } +} + +IMPORTANT NOTES: +- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED) +- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name +- Agents can have empty tools {} if they don't need external tools +- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters +- If all parameters are optional, you can omit the "required" field entirely +- Property descriptions in inputSchema are optional but helpful for clarity +- All other fields marked REQUIRED must always be present + +EXAMPLE 1 - Firecrawl Search Tool (with required params): +{ + "tools": { + "search": { + "type": "mcp", + "name": "firecrawl_search", + "description": "Search the web", + "mcpServerName": "firecrawl", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "limit": {"type": "number", "description": "Number of results"}, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["web", "images", "news"]} + }, + "required": ["type"] + } + } + }, + "required": ["query"] + } + } + } +} + +EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array): +{ + "tools": { + "text_to_speech": { + "type": "mcp", + "name": "text_to_speech", + "description": "Generate audio from text", + "mcpServerName": "elevenLabs", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"} + } + } + } + } +} + +CRITICAL NAMING AND ORGANIZATION RULES: +- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") +- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json → "name": "workflow_name") +- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"}) +- All three must be identical: filename, JSON "name" field, and workflow step "id" field +- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json) +- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json) +- NEVER create workflows or agents outside these designated folders +- Always maintain this naming and organizational consistency when creating or updating files + +YOUR CAPABILITIES: +1. Explore the directory structure to understand existing workflows/agents +2. Create new workflows and agents following best practices +3. Update existing files intelligently +4. Read and analyze file contents to maintain consistency +5. Suggest improvements and ask clarifying questions when needed +6. Execute shell commands to perform system operations + - Use executeCommand to run bash/shell commands + - Can list files, check system info, run scripts, etc. + - Commands execute in the .rowboat directory by default +7. List and explore MCP (Model Context Protocol) servers and their available tools + - Use listMcpServers to see all configured MCP servers + - Use listMcpTools to see what tools are available in a specific MCP server + - This helps users understand what external integrations they can use in their workflows + +MCP INTEGRATION: +- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs) +- MCP configuration is stored in config/mcp.json +- When users ask about available integrations or tools, check MCP servers +- Help users understand which MCP tools they can add to their agents + +DELETION RULES: +- When a user asks to delete a WORKFLOW, you MUST: + 1. First read/analyze the workflow to identify which agents it uses + 2. List those agents to the user + 3. Ask the user if they want to delete those agents as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete what the user confirms +- When a user asks to delete an AGENT, you MUST: + 1. First read/analyze the agent to identify which workflows it is used in + 2. List those workflows to the user + 3. Ask the user if they want to delete/modify those workflows as well + 4. Wait for their response before proceeding with any deletions + 5. Only delete/modify what the user confirms + +COMMUNICATION STYLE: +- Break down complex tasks into clear steps +- Explore existing files/structure before creating new ones +- Explain your reasoning as you work through tasks +- Be proactive in understanding context +- Confirm what you've done and suggest next steps +- Always ask for confirmation before destructive operations!! + +Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`; \ No newline at end of file diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index f28a03fb..24c0d013 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -34,7 +34,7 @@ const baseModelConfig: z.infer = { }, defaults: { provider: "openai", - model: "gpt-4.1", + model: "gpt-5", } }; @@ -55,7 +55,6 @@ function ensureModelConfig() { function ensureDirs() { const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }; ensure(WorkDir); - ensure(path.join(WorkDir, "workflows")); ensure(path.join(WorkDir, "agents")); ensure(path.join(WorkDir, "config")); ensureMcpConfig(); diff --git a/apps/cli/src/application/entities/agent.ts b/apps/cli/src/application/entities/agent.ts index e2cd52a4..3c74f109 100644 --- a/apps/cli/src/application/entities/agent.ts +++ b/apps/cli/src/application/entities/agent.ts @@ -1,28 +1,28 @@ import { z } from "zod"; -export const BaseAgentTool = z.object({ +export const BaseTool = z.object({ name: z.string(), }); -export const BuiltinAgentTool = BaseAgentTool.extend({ +export const BuiltinTool = BaseTool.extend({ type: z.literal("builtin"), }); -export const McpAgentTool = BaseAgentTool.extend({ +export const McpTool = BaseTool.extend({ type: z.literal("mcp"), description: z.string(), inputSchema: z.any(), mcpServerName: z.string(), }); -export const WorkflowAgentTool = BaseAgentTool.extend({ - type: z.literal("workflow"), +export const AgentAsATool = BaseTool.extend({ + type: z.literal("agent"), }); -export const AgentTool = z.discriminatedUnion("type", [ - BuiltinAgentTool, - McpAgentTool, - WorkflowAgentTool, +export const ToolAttachment = z.discriminatedUnion("type", [ + BuiltinTool, + McpTool, + AgentAsATool, ]); export const Agent = z.object({ @@ -31,5 +31,5 @@ export const Agent = z.object({ model: z.string().optional(), description: z.string(), instructions: z.string(), - tools: z.record(z.string(), AgentTool).optional(), + tools: z.record(z.string(), ToolAttachment).optional(), }); diff --git a/apps/cli/src/application/entities/llm-step-event.ts b/apps/cli/src/application/entities/llm-step-events.ts similarity index 100% rename from apps/cli/src/application/entities/llm-step-event.ts rename to apps/cli/src/application/entities/llm-step-events.ts diff --git a/apps/cli/src/application/entities/workflow-event.ts b/apps/cli/src/application/entities/run-events.ts similarity index 81% rename from apps/cli/src/application/entities/workflow-event.ts rename to apps/cli/src/application/entities/run-events.ts index 2e463836..47f27ce9 100644 --- a/apps/cli/src/application/entities/workflow-event.ts +++ b/apps/cli/src/application/entities/run-events.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { LlmStepStreamEvent } from "./llm-step-event.js"; -import { Workflow } from "./workflow.js"; +import { LlmStepStreamEvent } from "./llm-step-events.js"; import { Message } from "./message.js"; +import { Agent } from "./agent.js"; const BaseRunEvent = z.object({ ts: z.iso.datetime().optional(), @@ -10,47 +10,39 @@ const BaseRunEvent = z.object({ export const RunStartEvent = BaseRunEvent.extend({ type: z.literal("start"), runId: z.string(), - workflowId: z.string(), - workflow: Workflow, + agentId: z.string(), + agent: Agent, interactive: z.boolean(), }); export const RunStepStartEvent = BaseRunEvent.extend({ type: z.literal("step-start"), - stepIndex: z.number(), - stepId: z.string(), - stepType: z.enum(["agent", "function"]), }); export const RunStreamEvent = BaseRunEvent.extend({ type: z.literal("stream-event"), - stepId: z.string(), event: LlmStepStreamEvent, }); export const RunMessageEvent = BaseRunEvent.extend({ type: z.literal("message"), - stepId: z.string(), message: Message, }); export const RunToolInvocationEvent = BaseRunEvent.extend({ type: z.literal("tool-invocation"), - stepId: z.string(), toolName: z.string(), input: z.string(), }); export const RunToolResultEvent = BaseRunEvent.extend({ type: z.literal("tool-result"), - stepId: z.string(), toolName: z.string(), result: z.any(), }); export const RunStepEndEvent = BaseRunEvent.extend({ type: z.literal("step-end"), - stepIndex: z.number(), }); export const RunEndEvent = BaseRunEvent.extend({ diff --git a/apps/cli/src/application/entities/workflow.ts b/apps/cli/src/application/entities/workflow.ts deleted file mode 100644 index 804dad3c..00000000 --- a/apps/cli/src/application/entities/workflow.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod"; - -const AgentStep = z.object({ - type: z.literal("agent"), - id: z.string(), -}); - -const FunctionStep = z.object({ - type: z.literal("function"), - id: z.string(), -}); - -export const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]); - -export const Workflow = z.object({ - name: z.string(), - description: z.string(), - steps: z.array(Step), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); \ No newline at end of file diff --git a/apps/cli/src/application/functions/get_date.ts b/apps/cli/src/application/functions/get_date.ts deleted file mode 100644 index e8561d80..00000000 --- a/apps/cli/src/application/functions/get_date.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod"; -import { Step, StepOutputT } from "../lib/step.js"; -import { AgentTool } from "../entities/agent.js"; - -export class GetDate implements Step { - async* execute(): StepOutputT { - yield { - type: "text-start", - }; - yield { - type: "text-delta", - delta: 'The current date is ' + new Date().toISOString(), - }; - yield { - type: "text-end", - }; - } - - tools(): Record> { - return {}; - } -} \ No newline at end of file diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index e43d2427..4881e2b4 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -1,29 +1,22 @@ -import { Message, MessageList } from "../entities/message.js"; -import { z } from "zod"; -import { Step, StepInputT, StepOutputT } from "./step.js"; -import { ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai"; -import { Agent, AgentTool } from "../entities/agent.js"; -import { ModelConfig, WorkDir } from "../config/config.js"; +import { jsonSchema, ModelMessage } from "ai"; import fs from "fs"; import path from "path"; -import { loadWorkflow } from "./utils.js"; +import { ModelConfig, WorkDir } from "../config/config.js"; +import { Agent, ToolAttachment } from "../entities/agent.js"; +import { createInterface, Interface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { AssistantContentPart, AssistantMessage, Message, MessageList, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js"; +import { runIdGenerator } from "./run-id-gen.js"; +import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; +import { z } from "zod"; import { getProvider } from "./models.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; +import { execTool } from "./exec-tool.js"; +import { RunEvent } from "../entities/run-events.js"; +import { CopilotAgent } from "../assistant/agent.js"; +import { BuiltinTools } from "./builtin-tools.js"; -const BashTool = tool({ - description: "Run a command in the shell", - inputSchema: z.object({ - command: z.string(), - }), -}); - -const AskHumanTool = tool({ - description: "Ask the human for input", - inputSchema: z.object({ - question: z.string(), - }), -}); - -function mapAgentTool(t: z.infer): Tool { +export async function mapAgentTool(t: z.infer): Promise { switch (t.type) { case "mcp": return tool({ @@ -31,31 +24,136 @@ function mapAgentTool(t: z.infer): Tool { description: t.description, inputSchema: jsonSchema(t.inputSchema), }); - case "workflow": - const workflow = loadWorkflow(t.name); - if (!workflow) { - throw new Error(`Workflow ${t.name} not found`); + case "agent": + const agent = await loadAgent(t.name); + if (!agent) { + throw new Error(`Agent ${t.name} not found`); } return tool({ name: t.name, - description: workflow.description, + description: agent.description, inputSchema: z.object({ message: z.string().describe("The message to send to the workflow"), }), }); case "builtin": - switch (t.name) { - case "bash": - return BashTool; - case "ask-human": - return AskHumanTool; - default: - throw new Error(`Unknown builtin tool: ${t.name}`); + const match = BuiltinTools[t.name]; + if (!match) { + throw new Error(`Unknown builtin tool: ${t.name}`); } + return tool({ + description: match.description, + inputSchema: match.inputSchema, + }); } } -function convertFromMessages(messages: z.infer[]): ModelMessage[] { +export class RunLogger { + private logFile: string; + private fileHandle: fs.WriteStream; + + ensureRunsDir() { + const runsDir = path.join(WorkDir, "runs"); + if (!fs.existsSync(runsDir)) { + fs.mkdirSync(runsDir, { recursive: true }); + } + } + + constructor(runId: string) { + this.ensureRunsDir(); + this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); + this.fileHandle = fs.createWriteStream(this.logFile, { + flags: "a", + encoding: "utf8", + }); + } + + log(event: z.infer) { + if (event.type !== "stream-event") { + this.fileHandle.write(JSON.stringify(event) + "\n"); + } + } + + close() { + this.fileHandle.close(); + } +} + +export class LogAndYield { + private logger: RunLogger + + constructor(logger: RunLogger) { + this.logger = logger; + } + + async *logAndYield(event: z.infer): AsyncGenerator, void, unknown> { + const ev = { + ...event, + ts: new Date().toISOString(), + } + this.logger.log(ev); + yield ev; + } +} + +export class StreamStepMessageBuilder { + private parts: z.infer[] = []; + private textBuffer: string = ""; + private reasoningBuffer: string = ""; + + flushBuffers() { + // skip reasoning + // if (this.reasoningBuffer) { + // this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); + // this.reasoningBuffer = ""; + // } + if (this.textBuffer) { + this.parts.push({ type: "text", text: this.textBuffer }); + this.textBuffer = ""; + } + } + + ingest(event: z.infer) { + switch (event.type) { + case "reasoning-start": + case "reasoning-end": + case "text-start": + case "text-end": + this.flushBuffers(); + break; + case "reasoning-delta": + this.reasoningBuffer += event.delta; + break; + case "text-delta": + this.textBuffer += event.delta; + break; + case "tool-call": + this.parts.push({ + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + arguments: event.input, + }); + break; + } + } + + get(): z.infer { + this.flushBuffers(); + return { + role: "assistant", + content: this.parts, + }; + } +} + +export async function loadAgent(id: string): Promise> { + const agentPath = path.join(WorkDir, "agents", `${id}.json`); + const agent = fs.readFileSync(agentPath, "utf8"); + return Agent.parse(JSON.parse(agent)); +} + +export function convertFromMessages(messages: z.infer[]): ModelMessage[] { const result: ModelMessage[] = []; for (const msg of messages) { switch (msg.role) { @@ -119,100 +217,275 @@ function convertFromMessages(messages: z.infer[]): ModelMessage[ return result; } -export class AgentNode implements Step { - private id: string; - private asTool: boolean; - private agent: z.infer; - constructor(id: string, asTool: boolean) { - this.id = id; - this.asTool = asTool; - const agentPath = path.join(WorkDir, "agents", `${id}.json`); - const agent = fs.readFileSync(agentPath, "utf8"); - this.agent = Agent.parse(JSON.parse(agent)); - } +export async function* streamAgent(opts: { + agent: string; + runId?: string; + input?: string; + interactive?: boolean; +}) { + const messages: z.infer = []; - tools(): Record> { - return this.agent.tools ?? {}; - } - - async* execute(input: StepInputT): StepOutputT { - // console.log("\n\n\t>>>>\t\tinput", JSON.stringify(input)); - const tools: ToolSet = {}; - // if (!this.background) { - // tools["ask-human"] = AskHumanTool; - // } - for (const [name, tool] of Object.entries(this.agent.tools ?? {})) { - if (this.asTool && name === "ask-human") { - continue; - } - try { - tools[name] = mapAgentTool(tool); - } catch (error) { - console.error(`Error mapping tool ${name}:`, error); - continue; - } - } - - // console.log("\n\n\t>>>>\t\ttools", JSON.stringify(tools, null, 2)); - - const provider = getProvider(this.agent.provider); - const { fullStream } = streamText({ - model: provider(this.agent.model || ModelConfig.defaults.model), - messages: convertFromMessages(input), - system: this.agent.instructions, - stopWhen: stepCountIs(1), - tools, - }); - - for await (const event of fullStream) { - // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); - switch (event.type) { - case "reasoning-start": - yield { - type: "reasoning-start", - }; - break; - case "reasoning-delta": - yield { - type: "reasoning-delta", - delta: event.text, - }; - break; - case "reasoning-end": - yield { - type: "reasoning-end", - }; - break; - case "text-start": - yield { - type: "text-start", - }; - break; - case "text-delta": - yield { - type: "text-delta", - delta: event.text, - }; - break; - case "tool-call": - yield { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - }; - break; - case "finish": - yield { - type: "usage", - usage: event.totalUsage, - }; - break; - default: - // console.warn("Unknown event type", event); + // load existing and assemble state if required + if (opts.runId) { + console.error("loading run", opts.runId); + let stream: fs.ReadStream | null = null; + let rl: Interface | null = null; + try { + const logFile = path.join(WorkDir, "runs", `${opts.runId}.jsonl`); + stream = fs.createReadStream(logFile, { encoding: "utf8" }); + rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (line.trim() === "") { continue; + } + const parsed = JSON.parse(line); + const event = RunEvent.parse(parsed); + switch (event.type) { + case "message": + messages.push(event.message); + break; + } } + } finally { + stream?.close(); } } -} \ No newline at end of file + + // create runId if not present + if (!opts.runId) { + opts.runId = runIdGenerator.next(); + } + + // load agent data + let agent: z.infer | null = null; + if (opts.agent === "copilot") { + agent = CopilotAgent; + } else { + agent = await loadAgent(opts.agent); + } + if (!agent) { + throw new Error("unable to load agent"); + } + + // set up tools + const tools: ToolSet = {}; + for (const [name, tool] of Object.entries(agent.tools ?? {})) { + try { + tools[name] = await mapAgentTool(tool); + } catch (error) { + console.error(`Error mapping tool ${name}:`, error); + continue; + } + } + + // set up + const logger = new RunLogger(opts.runId); + const ly = new LogAndYield(logger); + const provider = getProvider(agent.provider); + const model = provider(agent.model || ModelConfig.defaults.model); + + // get first input if needed + let rl: Interface | null = null; + if (opts.interactive) { + rl = createInterface({ input, output }); + } + if (opts.input) { + const m: z.infer = { + role: "user", + content: opts.input, + }; + messages.push(m); + yield *ly.logAndYield({ + type: "message", + message: m, + }); + } + try { + // loop b/w user and agent + while (true) { + // get input in interactive mode when last message is not user + if (opts.interactive && (messages.length === 0 || messages[messages.length - 1].role !== "user")) { + const input = await rl!.question("You: "); + // Exit condition + if (["q", "quit", "exit"].includes(input.toLowerCase())) { + console.log("\n👋 Goodbye!"); + return; + } + + const m: z.infer = { + role: "user", + content: input, + }; + messages.push(m); + yield* ly.logAndYield({ + type: "message", + message: m, + }); + } + + // inner loop to handle tool calls + while (true) { + // stream agent response and build message + const messageBuilder = new StreamStepMessageBuilder(); + for await (const event of streamLlm( + model, + messages, + agent.instructions, + tools, + )) { + messageBuilder.ingest(event); + yield* ly.logAndYield({ + type: "stream-event", + event: event, + }); + } + + // build and emit final message from agent response + const msg = messageBuilder.get(); + messages.push(msg); + yield* ly.logAndYield({ + type: "message", + message: msg, + }); + + // handle tool calls + const mappedToolCalls: z.infer[] = []; + let msgToolCallParts: z.infer[] = []; + if (msg.content instanceof Array) { + msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); + } + const hasToolCalls = msgToolCallParts.length > 0; + console.log(msgToolCallParts); + + // validate and map tool calls + for (const part of msgToolCallParts) { + const agentTool = tools[part.toolName]; + if (!agentTool) { + throw new Error(`Tool ${part.toolName} not found`); + } + mappedToolCalls.push({ + toolCall: part, + agentTool: agent.tools![part.toolName], + }); + } + + for (const call of mappedToolCalls) { + const { agentTool, toolCall } = call; + yield* ly.logAndYield({ + type: "tool-invocation", + toolName: toolCall.toolName, + input: JSON.stringify(toolCall.arguments), + }); + const result = await execTool(agentTool, toolCall.arguments); + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; + messages.push(resultMsg); + yield* ly.logAndYield({ + type: "tool-result", + toolName: toolCall.toolName, + result: result, + }); + yield* ly.logAndYield({ + type: "message", + message: resultMsg, + }); + } + + // if the agent response had tool calls, replay this agent + if (hasToolCalls) { + continue; + } + + // otherwise, break + break; + } + + // if not interactive, return + if (!opts.interactive) { + break; + } + } + } finally { + rl?.close(); + logger.close(); + } +} + +async function* streamLlm( + model: LanguageModel, + messages: z.infer, + instructions: string, + tools: ToolSet, +): AsyncGenerator, void, unknown> { + const { fullStream } = streamText({ + model, + messages: convertFromMessages(messages), + system: instructions, + tools, + stopWhen: stepCountIs(1), + providerOptions: { + openai: { + reasoningEffort: "low", + reasoningSummary: "auto", + }, + } + }); + for await (const event of fullStream) { + // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); + switch (event.type) { + case "reasoning-start": + yield { + type: "reasoning-start", + }; + break; + case "reasoning-delta": + yield { + type: "reasoning-delta", + delta: event.text, + }; + break; + case "reasoning-end": + yield { + type: "reasoning-end", + }; + break; + case "text-start": + yield { + type: "text-start", + }; + break; + case "text-delta": + yield { + type: "text-delta", + delta: event.text, + }; + break; + case "tool-call": + yield { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + }; + break; + case "finish": + yield { + type: "usage", + usage: event.totalUsage, + }; + break; + default: + // console.warn("Unknown event type", event); + continue; + } + } +} +export const MappedToolCall = z.object({ + toolCall: ToolCallPart, + agentTool: ToolAttachment, +}); diff --git a/apps/cli/src/application/lib/builtin-tools.ts b/apps/cli/src/application/lib/builtin-tools.ts new file mode 100644 index 00000000..9af6a9fe --- /dev/null +++ b/apps/cli/src/application/lib/builtin-tools.ts @@ -0,0 +1,424 @@ +import { z, ZodType } from "zod"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { WorkDir as BASE_DIR } from "../config/config.js"; +import { executeCommand } from "./command-executor.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client } from "@modelcontextprotocol/sdk/client"; + +const BuiltinToolsSchema = z.record(z.string(), z.object({ + description: z.string(), + inputSchema: z.custom(), + execute: z.function({ + input: z.any(), + output: z.promise(z.any()), + }), +})); + +export const BuiltinTools: z.infer = { + exploreDirectory: { + description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), + maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), + }), + execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => { + async function explore(dir: string, depth: number = 0): Promise { + if (depth > maxDepth) return null; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const result: any = { files: [], directories: {} }; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isFile()) { + const ext = path.extname(entry.name); + const size = (await fs.stat(fullPath)).size; + result.files.push({ + name: entry.name, + type: ext || 'no-extension', + size: size, + relativePath: path.relative(BASE_DIR, fullPath), + }); + } else if (entry.isDirectory()) { + result.directories[entry.name] = await explore(fullPath, depth + 1); + } + } + + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const structure = await explore(dirPath); + + return { + success: true, + basePath: path.relative(BASE_DIR, dirPath) || '.', + structure, + }; + }, + }, + + readFile: { + description: 'Read and parse file contents. For JSON files, provides parsed structure.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'), + }), + execute: async ({ filename }: { filename: string }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const content = await fs.readFile(filePath, 'utf-8'); + + let parsed = null; + let fileType = path.extname(filename); + + if (fileType === '.json') { + try { + parsed = JSON.parse(content); + } catch { + parsed = { error: 'Invalid JSON' }; + } + } + + return { + success: true, + filename, + fileType, + content, + parsed, + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + createFile: { + description: 'Create a new file with content. Automatically creates parent directories if needed.', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'), + content: z.string().describe('The content to write to the file'), + description: z.string().optional().describe('Optional description of why this file is being created'), + }), + execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => { + try { + const filePath = path.join(BASE_DIR, filename); + const dir = path.dirname(filePath); + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }); + + // Write file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' created successfully`, + description: description || 'No description provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + updateFile: { + description: 'Update or overwrite the contents of an existing file', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'), + content: z.string().describe('The new content to write to the file'), + reason: z.string().optional().describe('Optional reason for the update'), + }), + execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => { + try { + const filePath = path.join(BASE_DIR, filename); + + // Check if file exists + await fs.access(filePath); + + // Update file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + success: true, + message: `File '${filename}' updated successfully`, + reason: reason || 'No reason provided', + path: filePath, + size: content.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + deleteFile: { + description: 'Delete a file from the .rowboat directory', + inputSchema: z.object({ + filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'), + }), + execute: async ({ filename }: { filename: string }) => { + try { + const filePath = path.join(BASE_DIR, filename); + await fs.unlink(filePath); + + return { + success: true, + message: `File '${filename}' deleted successfully`, + path: filePath, + }; + } catch (error) { + return { + success: false, + message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + listFiles: { + description: 'List all files and directories in the .rowboat directory or subdirectory', + inputSchema: z.object({ + subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'), + }), + execute: async ({ subdirectory }: { subdirectory?: string }) => { + try { + const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR; + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + const files = entries + .filter(entry => entry.isFile()) + .map(entry => ({ + name: entry.name, + type: path.extname(entry.name) || 'no-extension', + relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)), + })); + + const directories = entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + + return { + success: true, + path: dirPath, + relativePath: path.relative(BASE_DIR, dirPath) || '.', + files, + directories, + totalFiles: files.length, + totalDirectories: directories.length, + }; + } catch (error) { + return { + success: false, + message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + analyzeWorkflow: { + description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies', + inputSchema: z.object({ + workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'), + }), + execute: async ({ workflowName }: { workflowName: string }) => { + try { + const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`; + const filePath = path.join(BASE_DIR, 'workflows', filename); + + const content = await fs.readFile(filePath, 'utf-8'); + const workflow = JSON.parse(content); + + // Extract key information + const analysis = { + name: workflow.name, + description: workflow.description || 'No description', + agentCount: workflow.agents ? workflow.agents.length : 0, + agents: workflow.agents || [], + tools: workflow.tools || {}, + structure: workflow, + }; + + return { + success: true, + filePath: path.relative(BASE_DIR, filePath), + analysis, + }; + } catch (error) { + return { + success: false, + message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + listMcpServers: { + description: 'List all available MCP servers from the configuration', + inputSchema: z.object({}), + execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => { + try { + const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); + + // Check if config exists + try { + await fs.access(configPath); + } catch { + return { + success: true, + servers: [], + count: 0, + message: 'No MCP servers configured yet', + }; + } + + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + const servers = Object.keys(config.mcpServers || {}).map(name => { + const server = config.mcpServers[name]; + return { + name, + type: 'command' in server ? 'stdio' : 'http', + command: server.command, + url: server.url, + }; + }); + + return { + success: true, + servers, + count: servers.length, + message: `Found ${servers.length} MCP server(s)`, + }; + } catch (error) { + return { + success: false, + servers: [], + count: 0, + message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + listMcpTools: { + description: 'List all available tools from a specific MCP server', + inputSchema: z.object({ + serverName: z.string().describe('Name of the MCP server to query'), + }), + execute: async ({ serverName }: { serverName: string }) => { + try { + const configPath = path.join(BASE_DIR, 'config', 'mcp.json'); + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + const mcpConfig = config.mcpServers[serverName]; + if (!mcpConfig) { + return { + success: false, + message: `MCP server '${serverName}' not found in configuration`, + }; + } + + // Create transport based on config type + let transport; + if ('command' in mcpConfig) { + transport = new StdioClientTransport({ + command: mcpConfig.command, + args: mcpConfig.args || [], + env: mcpConfig.env || {}, + }); + } else { + try { + transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url)); + } catch { + transport = new SSEClientTransport(new URL(mcpConfig.url)); + } + } + + // Create and connect client + const client = new Client({ + name: 'rowboat-copilot', + version: '1.0.0', + }); + + await client.connect(transport); + + // List available tools + const toolsList = await client.listTools(); + + // Close connection + client.close(); + transport.close(); + + const tools = toolsList.tools.map((t: any) => ({ + name: t.name, + description: t.description || 'No description', + inputSchema: t.inputSchema, + })); + + return { + success: true, + serverName, + tools, + count: tools.length, + message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`, + }; + } catch (error) { + return { + success: false, + message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + }, + }, + + executeCommand: { + description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', + inputSchema: z.object({ + command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'), + cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'), + }), + execute: async ({ command, cwd }: { command: string, cwd?: string }) => { + try { + const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR; + const result = await executeCommand(command, { cwd: workingDir }); + + return { + success: result.exitCode === 0, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + command, + workingDir, + }; + } catch (error) { + return { + success: false, + message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`, + command, + }; + } + }, + }, +}; \ No newline at end of file diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index b6485355..d0409365 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -1,20 +1,16 @@ -import { tool, Tool } from "ai"; -import { AgentTool } from "../entities/agent.js"; +import { ToolAttachment } from "../entities/agent.js"; import { z } from "zod"; import { McpServers } from "../config/config.js"; -import { getMcpClient } from "./mcp.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Client } from "@modelcontextprotocol/sdk/client"; -import { executeCommand } from "./command-executor.js"; -import { loadWorkflow } from "./utils.js"; import { AssistantMessage } from "../entities/message.js"; -import { executeWorkflow } from "./exec-workflow.js"; -import readline from "readline"; +import { BuiltinTools } from "./builtin-tools.js"; +import { streamAgent } from "./agent.js"; -async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { +async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { // load mcp configuration from the tool const mcpConfig = McpServers[agentTool.mcpServerName]; if (!mcpConfig) { @@ -57,34 +53,12 @@ async function execMcpTool(agentTool: z.infer & { type: "mcp" return result; } -async function execBashTool(agentTool: z.infer, input: any): Promise { - const result = await executeCommand(input.command as string); - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }; -} - -export async function execAskHumanTool(agentTool: z.infer, question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - let p = new Promise((resolve, reject) => { - rl.question(`>> Provide answer to: ${question}:\n\n`, (answer) => { - resolve(answer); - rl.close(); - }); - }); - const answer = await p; - return answer; -} - -async function execWorkflowTool(agentTool: z.infer & { type: "workflow" }, input: any): Promise { +async function execAgentTool(agentTool: z.infer & { type: "agent" }, input: any): Promise { let lastMsg: z.infer | null = null; - for await (const event of executeWorkflow(agentTool.name, input.message)) { + for await (const event of streamAgent({ + agent: agentTool.name, + input: JSON.stringify(input), + })) { if (event.type === "message" && event.message.role === "assistant") { lastMsg = event.message; } @@ -94,7 +68,7 @@ async function execWorkflowTool(agentTool: z.infer & { type: " } if (!lastMsg) { - throw new Error("No message received from workflow"); + throw new Error("No message received from agent"); } if (typeof lastMsg.content === "string") { return lastMsg.content; @@ -107,18 +81,17 @@ async function execWorkflowTool(agentTool: z.infer & { type: " }, ""); } -export async function execTool(agentTool: z.infer, input: any): Promise { +export async function execTool(agentTool: z.infer, input: any): Promise { switch (agentTool.type) { case "mcp": return execMcpTool(agentTool, input); - case "workflow": - return execWorkflowTool(agentTool, input); + case "agent": + return execAgentTool(agentTool, input); case "builtin": - switch (agentTool.name) { - case "bash": - return execBashTool(agentTool, input); - default: - throw new Error(`Unknown builtin tool: ${agentTool.name}`); + const builtinTool = BuiltinTools[agentTool.name]; + if (!builtinTool || !builtinTool.execute) { + throw new Error(`Unsupported builtin tool: ${agentTool.name}`); } + return builtinTool.execute(input); } } \ No newline at end of file diff --git a/apps/cli/src/application/lib/exec-workflow.ts b/apps/cli/src/application/lib/exec-workflow.ts deleted file mode 100644 index 37421665..00000000 --- a/apps/cli/src/application/lib/exec-workflow.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { loadWorkflow } from "./utils.js"; -import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage, ToolCallPart } from "../entities/message.js"; -import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; -import { AgentNode } from "./agent.js"; -import { z } from "zod"; -import path from "path"; -import { WorkDir } from "../config/config.js"; -import fs from "fs"; -import { createInterface, Interface } from "node:readline/promises"; -import { FunctionsRegistry } from "../registry/functions.js"; -import { RunEvent } from "../entities/workflow-event.js"; -import { execAskHumanTool, execTool } from "./exec-tool.js"; -import { AgentTool } from "../entities/agent.js"; -import { runIdGenerator } from "./run-id-gen.js"; -import { Workflow } from "../entities/workflow.js"; - -const MappedToolCall = z.object({ - toolCall: ToolCallPart, - agentTool: AgentTool, -}); - -const State = z.object({ - stepIndex: z.number(), - messages: MessageList, - workflow: Workflow.nullable(), - pendingToolCallId: z.string().nullable(), -}); - -class StateBuilder { - private state: z.infer = { - stepIndex: 0, - messages: [], - workflow: null, - pendingToolCallId: null, - }; - - ingest(event: z.infer) { - switch (event.type) { - case "start": - this.state.workflow = event.workflow; - break; - case "step-start": - this.state.stepIndex = event.stepIndex; - break; - case "message": - this.state.messages.push(event.message); - this.state.pendingToolCallId = null; - break; - case "pause-for-human-input": - this.state.pendingToolCallId = event.toolCallId; - break; - } - } - - get(): z.infer { - return this.state; - } -} - -class RunLogger { - private logFile: string; - private fileHandle: fs.WriteStream; - - ensureRunsDir(workflowId: string) { - const runsDir = path.join(WorkDir, "runs", workflowId); - if (!fs.existsSync(runsDir)) { - fs.mkdirSync(runsDir, { recursive: true }); - } - } - - constructor(workflowId: string, runId: string) { - this.ensureRunsDir(workflowId); - this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); - this.fileHandle = fs.createWriteStream(this.logFile, { - flags: "a", - encoding: "utf8", - }); - } - - log(event: z.infer) { - this.fileHandle.write(JSON.stringify(event) + "\n"); - } - - close() { - this.fileHandle.close(); - } -} - -class LogAndYield { - private logger: RunLogger - - constructor(logger: RunLogger) { - this.logger = logger; - } - - async *logAndYield(event: z.infer): AsyncGenerator, void, unknown> { - const ev = { - ...event, - ts: new Date().toISOString(), - } - this.logger.log(ev); - yield ev; - } -} - -class StreamStepMessageBuilder { - private parts: z.infer[] = []; - private textBuffer: string = ""; - private reasoningBuffer: string = ""; - - flushBuffers() { - if (this.reasoningBuffer) { - this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); - this.reasoningBuffer = ""; - } - if (this.textBuffer) { - this.parts.push({ type: "text", text: this.textBuffer }); - this.textBuffer = ""; - } - } - - ingest(event: z.infer) { - switch (event.type) { - case "reasoning-start": - case "reasoning-end": - case "text-start": - case "text-end": - this.flushBuffers(); - break; - case "reasoning-delta": - this.reasoningBuffer += event.delta; - break; - case "text-delta": - this.textBuffer += event.delta; - break; - case "tool-call": - this.parts.push({ - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - arguments: event.input, - }); - break; - } - } - - get(): z.infer { - this.flushBuffers(); - return { - role: "assistant", - content: this.parts, - }; - } -} - -function loadFunction(id: string) { - const func = FunctionsRegistry[id]; - if (!func) { - throw new Error(`Function ${id} not found`); - } - return func; -} - -export async function* executeWorkflow(id: string, input: string, interactive: boolean = true, asTool: boolean = false): AsyncGenerator, void, unknown> { - const runId = runIdGenerator.next(); - yield* runFromState({ - id, - runId, - state: { - stepIndex: 0, - messages: [{ - role: "user", - content: input, - }], - workflow: null, - pendingToolCallId: null, - }, - interactive, - asTool, - }); -} - -export async function* resumeWorkflow(runId: string, input: string, interactive: boolean = false): AsyncGenerator, void, unknown> { - // read a run.jsonl file line by line and build state - const builder = new StateBuilder(); - let rl: Interface | null = null; - let stream: fs.ReadStream | null = null; - try { - const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); - stream = fs.createReadStream(logFile, { encoding: "utf8" }); - rl = createInterface({ input: stream, crlfDelay: Infinity }); - for await (const line of rl) { - if (line.trim() === "") { - continue; - } - // console.error('processing line', line); - const parsed = JSON.parse(line); - // console.error('parsed'); - const event = RunEvent.parse(parsed); - // console.error('zod parsed'); - builder.ingest(event); - } - } catch (error) { - // console.error("Failed to resume workflow:", error); - // yield { - // type: "error", - // error: error instanceof Error ? error.message : String(error), - // }; - } finally { - rl?.close(); - stream?.close(); - } - - const { workflow, messages, stepIndex, pendingToolCallId } = builder.get(); - if (!workflow) { - throw new Error(`Workflow not found for run ${runId}`); - } - if (!pendingToolCallId) { - throw new Error(`No pending tool call found for run ${runId}`); - } - const stepId = workflow.steps[stepIndex].id; - - // append user input as message - const logger = new RunLogger(workflow.name, runId); - const ly = new LogAndYield(logger); - yield *ly.logAndYield({ - type: "resume" - }); - - // append user input as message - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(input), - toolCallId: pendingToolCallId, - toolName: "ask-human", - }; - messages.push(resultMsg); - yield* ly.logAndYield({ - type: "tool-result", - stepId, - toolName: "ask-human", - result: input, - }); - yield* ly.logAndYield({ - type: "message", - stepId, - message: resultMsg, - }); - - yield* runFromState({ - id: workflow.name, - runId, - state: { - stepIndex, - messages, - workflow, - pendingToolCallId, - }, - interactive, - asTool: false, - }); -} - -async function* runFromState(opts: { - id: string; - runId: string; - state: z.infer; - interactive: boolean; - asTool: boolean; -}) { - const { id, runId, state, interactive, asTool } = opts; - let stepIndex = state.stepIndex; - let messages = [...state.messages]; - let workflow = state.workflow; - - const logger = new RunLogger(id, runId); - const ly = new LogAndYield(logger); - - try { - if (!workflow) { - workflow = loadWorkflow(id); - - yield* ly.logAndYield({ - type: "start", - runId, - workflowId: id, - workflow, - interactive, - }); - } - - while (true) { - const step = workflow.steps[stepIndex]; - const node = step.type === "agent" ? new AgentNode(step.id, asTool) : loadFunction(step.id); - - yield* ly.logAndYield({ - type: "step-start", - stepIndex, - stepId: step.id, - stepType: step.type, - }); - - const messageBuilder = new StreamStepMessageBuilder(); - - // stream response from agent - for await (const event of node.execute(messages)) { - // console.log(" - event", JSON.stringify(event)); - messageBuilder.ingest(event); - yield* ly.logAndYield({ - type: "stream-event", - stepId: step.id, - event: event, - }); - } - - // build and emit final message from agent response - const msg = messageBuilder.get(); - messages.push(msg); - yield* ly.logAndYield({ - type: "message", - stepId: step.id, - message: msg, - }); - - // handle tool calls - const tools = node.tools(); - const mappedToolCalls: z.infer[] = []; - let msgToolCallParts: z.infer[] = []; - if (msg.content instanceof Array) { - msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); - } - const hasToolCalls = msgToolCallParts.length > 0; - - // validate and map tool calls - for (const part of msgToolCallParts) { - const agentTool = tools[part.toolName]; - if (!agentTool) { - throw new Error(`Tool ${part.toolName} not found`); - } - mappedToolCalls.push({ - toolCall: part, - agentTool: agentTool, - }); - } - - // first, exec all tool calls other than ask-human - for (const call of mappedToolCalls) { - const { agentTool, toolCall } = call; - if (agentTool.type === "builtin" && agentTool.name === "ask-human") { - continue; - } - yield* ly.logAndYield({ - type: "tool-invocation", - stepId: step.id, - toolName: toolCall.toolName, - input: JSON.stringify(toolCall.arguments), - }); - const result = await execTool(agentTool, toolCall.arguments); - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(result), - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - }; - messages.push(resultMsg); - yield* ly.logAndYield({ - type: "tool-result", - stepId: step.id, - toolName: toolCall.toolName, - result: result, - }); - yield* ly.logAndYield({ - type: "message", - stepId: step.id, - message: resultMsg, - }); - } - - // handle ask-tool call execution - for (const call of mappedToolCalls) { - const { agentTool, toolCall } = call; - if (agentTool.type !== "builtin" || agentTool.name !== "ask-human") { - continue; - } - yield* ly.logAndYield({ - type: "tool-invocation", - stepId: step.id, - toolName: toolCall.toolName, - input: JSON.stringify(toolCall.arguments), - }); - - // if running in background mode, exit here - if (!interactive) { - yield* ly.logAndYield({ - type: "pause-for-human-input", - toolCallId: toolCall.toolCallId, - }); - return; - } - const result = await execAskHumanTool(agentTool, toolCall.arguments.question as string); - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(result), - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - }; - messages.push(resultMsg); - yield* ly.logAndYield({ - type: "tool-result", - stepId: step.id, - toolName: toolCall.toolName, - result: result, - }); - yield* ly.logAndYield({ - type: "message", - stepId: step.id, - message: resultMsg, - }); - } - - yield* ly.logAndYield({ - type: "step-end", - stepIndex, - }); - - // if the agent response had tool calls, replay this agent - if (hasToolCalls) { - continue; - } - - // otherwise, move to the next step - stepIndex++; - if (stepIndex >= workflow.steps.length) { - yield* ly.logAndYield({ - type: "end", - }); - break; - } - } - // console.log('\n\n', JSON.stringify(messages, null, 2)); - } catch (error) { - yield* ly.logAndYield({ - type: "error", - error: error instanceof Error ? error.message : String(error), - }); - } finally { - logger.close(); - } -} diff --git a/apps/cli/src/application/lib/step.ts b/apps/cli/src/application/lib/step.ts index ec3c2146..3fae98bc 100644 --- a/apps/cli/src/application/lib/step.ts +++ b/apps/cli/src/application/lib/step.ts @@ -1,7 +1,7 @@ import { MessageList } from "../entities/message.js"; -import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; import { z } from "zod"; -import { AgentTool } from "../entities/agent.js"; +import { ToolAttachment } from "../entities/agent.js"; export type StepInputT = z.infer; export type StepOutputT = AsyncGenerator, void, unknown>; @@ -9,5 +9,5 @@ export type StepOutputT = AsyncGenerator, voi export interface Step { execute(input: StepInputT): StepOutputT; - tools(): Record>; + tools(): Record>; } \ No newline at end of file diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index b5a2cc57..7ac3279d 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { RunEvent } from "../entities/workflow-event.js"; -import { LlmStepStreamEvent } from "../entities/llm-step-event.js"; +import { RunEvent } from "../entities/run-events.js"; +import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; export interface StreamRendererOptions { showHeaders?: boolean; @@ -27,11 +27,11 @@ export class StreamRenderer { render(event: z.infer) { switch (event.type) { case "start": { - this.onWorkflowStart(event.workflowId, event.runId, event.interactive); + this.onStart(event.agentId, event.runId, event.interactive); break; } case "step-start": { - this.onStepStart(event.stepIndex, event.stepId, event.stepType); + this.onStepStart(); break; } case "stream-event": { @@ -43,23 +43,23 @@ export class StreamRenderer { break; } case "tool-invocation": { - this.onStepToolInvocation(event.stepId, event.toolName, event.input); + this.onStepToolInvocation(event.toolName, event.input); break; } case "tool-result": { - this.onStepToolResult(event.stepId, event.toolName, event.result); + this.onStepToolResult(event.toolName, event.result); break; } case "step-end": { - this.onStepEnd(event.stepIndex); + this.onStepEnd(); break; } case "end": { - this.onWorkflowEnd(); + this.onEnd(); break; } case "error": { - this.onWorkflowError(event.error); + this.onError(event.error); break; } } @@ -94,29 +94,29 @@ export class StreamRenderer { } } - private onWorkflowStart(workflowId: string, runId: string, interactive: boolean) { + private onStart(workflowId: string, runId: string, interactive: boolean) { this.write("\n"); this.write(this.bold(`▶ Workflow ${workflowId} (run ${runId})`)); if (!interactive) this.write(this.dim(" (--no-interactive)")); this.write("\n"); } - private onWorkflowEnd() { + private onEnd() { this.write(this.bold("\n■ Workflow complete\n")); } - private onWorkflowError(error: string) { + private onError(error: string) { this.write(this.red(`\n✖ Workflow error: ${error}\n`)); } - private onStepStart(stepIndex: number, stepId: string, stepType: "agent" | "function") { + private onStepStart() { this.write("\n"); - this.write(this.cyan(`─ Step ${stepIndex} [${stepType}]`)); + this.write(this.cyan(`─ Step started`)); this.write("\n"); } - private onStepEnd(stepIndex: number) { - this.write(this.dim(`✓ Step ${stepIndex} finished\n`)); + private onStepEnd() { + this.write(this.dim(`✓ Step finished\n`)); } private onStepMessage(stepIndex: number, message: any) { @@ -131,7 +131,7 @@ export class StreamRenderer { } } - private onStepToolInvocation(stepId: string, toolName: string, input: string) { + private onStepToolInvocation(toolName: string, input: string) { this.write(this.cyan(`\n→ Tool invoke ${toolName}`)); if (input && input.length) { this.write("\n" + this.dim(this.indent(this.truncate(input))) + "\n"); @@ -140,7 +140,7 @@ export class StreamRenderer { } } - private onStepToolResult(stepId: string, toolName: string, result: unknown) { + private onStepToolResult(toolName: string, result: unknown) { const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent)); this.write(this.cyan(`\n← Tool result ${toolName}\n`)); this.write(this.dim(this.indent(res)) + "\n"); diff --git a/apps/cli/src/application/lib/utils.ts b/apps/cli/src/application/lib/utils.ts deleted file mode 100644 index da211b36..00000000 --- a/apps/cli/src/application/lib/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { WorkDir } from "../config/config.js"; -import { Workflow } from "../entities/workflow.js"; - -export function loadWorkflow(id: string) { - const workflowPath = path.join(WorkDir, "workflows", `${id}.json`); - const workflow = fs.readFileSync(workflowPath, "utf8"); - return Workflow.parse(JSON.parse(workflow)); -} diff --git a/apps/cli/src/application/registry/functions.ts b/apps/cli/src/application/registry/functions.ts deleted file mode 100644 index 1d4c1a9b..00000000 --- a/apps/cli/src/application/registry/functions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GetDate } from "../functions/get_date.js"; -import { Step } from "../lib/step.js"; - -export const FunctionsRegistry: Record = { - get_date: new GetDate(), -} as const; \ No newline at end of file diff --git a/apps/cli/src/application/registry/tools.ts b/apps/cli/src/application/registry/tools.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/cli/src/x.ts b/apps/cli/src/x.ts deleted file mode 100644 index 9dbd8edb..00000000 --- a/apps/cli/src/x.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { startCopilot } from "./application/assistant/chat.js"; - -export const start = () => { - startCopilot().catch((err) => { - console.error("Failed to run copilot:", err); - process.exitCode = 1; - }); -} From 144bbe58785c95c3676c24bedb94b0912dfd9691 Mon Sep 17 00:00:00 2001 From: Arjun Date: Sun, 16 Nov 2025 11:36:50 +0530 Subject: [PATCH 23/38] moved assistant to use skills; added agent monitoring --- .../src/application/assistant/instructions.ts | 175 ++---------------- .../skills/deletion-guardrails/skill.ts | 24 +++ .../src/application/assistant/skills/index.ts | 143 ++++++++++++++ .../assistant/skills/mcp-integration/skill.ts | 60 ++++++ .../skills/workflow-authoring/skill.ts | 63 +++++++ .../skills/workflow-run-ops/skill.ts | 61 ++++++ apps/cli/src/application/lib/builtin-tools.ts | 27 ++- 7 files changed, 396 insertions(+), 157 deletions(-) create mode 100644 apps/cli/src/application/assistant/skills/deletion-guardrails/skill.ts create mode 100644 apps/cli/src/application/assistant/skills/index.ts create mode 100644 apps/cli/src/application/assistant/skills/mcp-integration/skill.ts create mode 100644 apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts create mode 100644 apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index c7a750b4..b5996d8b 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -1,164 +1,27 @@ +import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../config/config.js"; -export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. +export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR} -WORKFLOW KNOWLEDGE: -- Workflows are JSON files that orchestrate multiple agents -- Agents are JSON files defining AI assistants with specific tools and instructions -- Tools can be built-in functions or MCP (Model Context Protocol) integrations +Use the catalog below to decide which skills to load for each user request. Before acting: +- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string. +- Apply the instructions from every loaded skill while working on the request. -NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files +${skillCatalog} -CORRECT WORKFLOW FORMAT: -{ - "name": "workflow_name", // REQUIRED - must match filename - "description": "Description...", // REQUIRED - must be a description of the workflow - "steps": [ // REQUIRED - array of steps - { - "type": "agent", // REQUIRED - always "agent" - "id": "agent_name" // REQUIRED - must match agent filename - }, - { - "type": "agent", - "id": "another_agent_name" - } - ] -} +Always consult this catalog first so you load the right skills before taking action. -CORRECT AGENT FORMAT (with detailed tool structure): -{ - "name": "agent_name", // REQUIRED - must match filename - "description": "What agent does", // REQUIRED - must be a description of the agent - "model": "gpt-4.1", // REQUIRED - model to use - "instructions": "Instructions...", // REQUIRED - agent instructions - "tools": { // OPTIONAL - can be empty {} or omitted - "descriptive_tool_name": { - "type": "mcp", // REQUIRED - always "mcp" for MCP tools - "name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server - "description": "What tool does", // REQUIRED - clear description - "mcpServerName": "server_name", // REQUIRED - name from mcp.json config - "inputSchema": { // REQUIRED - full JSON schema - "type": "object", - "properties": { - "param1": { - "type": "string", - "description": "Description of param" // description is optional but helpful - } - }, - "required": ["param1"] // OPTIONAL - only include if params are required - } - } - } -} +# Communication & Execution Style -IMPORTANT NOTES: -- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED) -- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name -- Agents can have empty tools {} if they don't need external tools -- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters -- If all parameters are optional, you can omit the "required" field entirely -- Property descriptions in inputSchema are optional but helpful for clarity -- All other fields marked REQUIRED must always be present +## Communication principles +- Break complex efforts into clear, sequential steps the user can follow. +- Explain reasoning briefly as you work, and confirm outcomes before moving on. +- Be proactive about understanding missing context; ask clarifying questions when needed. +- Summarize completed work and suggest logical next steps at the end of a task. +- Always ask for confirmation before taking destructive actions. -EXAMPLE 1 - Firecrawl Search Tool (with required params): -{ - "tools": { - "search": { - "type": "mcp", - "name": "firecrawl_search", - "description": "Search the web", - "mcpServerName": "firecrawl", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "limit": {"type": "number", "description": "Number of results"}, - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": {"type": "string", "enum": ["web", "images", "news"]} - }, - "required": ["type"] - } - } - }, - "required": ["query"] - } - } - } -} - -EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array): -{ - "tools": { - "text_to_speech": { - "type": "mcp", - "name": "text_to_speech", - "description": "Generate audio from text", - "mcpServerName": "elevenLabs", - "inputSchema": { - "type": "object", - "properties": { - "text": {"type": "string"} - } - } - } - } -} - -CRITICAL NAMING AND ORGANIZATION RULES: -- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json → "name": "agent_name") -- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json → "name": "workflow_name") -- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"}) -- All three must be identical: filename, JSON "name" field, and workflow step "id" field -- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json) -- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json) -- NEVER create workflows or agents outside these designated folders -- Always maintain this naming and organizational consistency when creating or updating files - -YOUR CAPABILITIES: -1. Explore the directory structure to understand existing workflows/agents -2. Create new workflows and agents following best practices -3. Update existing files intelligently -4. Read and analyze file contents to maintain consistency -5. Suggest improvements and ask clarifying questions when needed -6. Execute shell commands to perform system operations - - Use executeCommand to run bash/shell commands - - Can list files, check system info, run scripts, etc. - - Commands execute in the .rowboat directory by default -7. List and explore MCP (Model Context Protocol) servers and their available tools - - Use listMcpServers to see all configured MCP servers - - Use listMcpTools to see what tools are available in a specific MCP server - - This helps users understand what external integrations they can use in their workflows - -MCP INTEGRATION: -- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs) -- MCP configuration is stored in config/mcp.json -- When users ask about available integrations or tools, check MCP servers -- Help users understand which MCP tools they can add to their agents - -DELETION RULES: -- When a user asks to delete a WORKFLOW, you MUST: - 1. First read/analyze the workflow to identify which agents it uses - 2. List those agents to the user - 3. Ask the user if they want to delete those agents as well - 4. Wait for their response before proceeding with any deletions - 5. Only delete what the user confirms -- When a user asks to delete an AGENT, you MUST: - 1. First read/analyze the agent to identify which workflows it is used in - 2. List those workflows to the user - 3. Ask the user if they want to delete/modify those workflows as well - 4. Wait for their response before proceeding with any deletions - 5. Only delete/modify what the user confirms - -COMMUNICATION STYLE: -- Break down complex tasks into clear steps -- Explore existing files/structure before creating new ones -- Explain your reasoning as you work through tasks -- Be proactive in understanding context -- Confirm what you've done and suggest next steps -- Always ask for confirmation before destructive operations!! - -Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`; \ No newline at end of file +## Execution reminders +- Explore existing files and structure before creating new assets. +- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. +- Keep user data safe—double-check before editing or deleting important resources. +`; diff --git a/apps/cli/src/application/assistant/skills/deletion-guardrails/skill.ts b/apps/cli/src/application/assistant/skills/deletion-guardrails/skill.ts new file mode 100644 index 00000000..e0355b8d --- /dev/null +++ b/apps/cli/src/application/assistant/skills/deletion-guardrails/skill.ts @@ -0,0 +1,24 @@ +export const skill = String.raw` +# Deletion Guardrails + +Load this skill when a user asks to delete agents or workflows so you follow the required confirmation steps. + +## Workflow deletion protocol +1. Read the workflow file to identify every agent it references. +2. Report those agents to the user and ask whether they should be deleted too. +3. Wait for explicit confirmation before deleting anything. +4. Only remove the workflow and/or agents the user authorizes. + +## Agent deletion protocol +1. Inspect the agent file to discover which workflows reference it. +2. List those workflows to the user and ask whether they should be updated or deleted. +3. Pause for confirmation before modifying workflows or removing the agent. +4. Perform only the deletions the user approves. + +## Safety checklist +- Never delete cascaded resources automatically. +- Keep a clear audit trail in your responses describing what was removed. +- If the user’s instructions are ambiguous, ask clarifying questions before taking action. +`; + +export default skill; diff --git a/apps/cli/src/application/assistant/skills/index.ts b/apps/cli/src/application/assistant/skills/index.ts new file mode 100644 index 00000000..17210983 --- /dev/null +++ b/apps/cli/src/application/assistant/skills/index.ts @@ -0,0 +1,143 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; +import mcpIntegrationSkill from "./mcp-integration/skill.js"; +import workflowAuthoringSkill from "./workflow-authoring/skill.js"; +import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; + +const CURRENT_FILE = fileURLToPath(import.meta.url); +const CURRENT_DIR = path.dirname(CURRENT_FILE); +const CATALOG_PREFIX = "src/application/assistant/skills"; + +type SkillDefinition = { + id: string; + title: string; + folder: string; + summary: string; + content: string; +}; + +type ResolvedSkill = { + id: string; + catalogPath: string; + content: string; +}; + +const definitions: SkillDefinition[] = [ + { + id: "workflow-authoring", + title: "Workflow Authoring", + folder: "workflow-authoring", + summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", + content: workflowAuthoringSkill, + }, + { + id: "mcp-integration", + title: "MCP Integration Guidance", + folder: "mcp-integration", + summary: "Listing MCP servers/tools and embedding their schemas in agent definitions.", + content: mcpIntegrationSkill, + }, + { + id: "deletion-guardrails", + title: "Deletion Guardrails", + folder: "deletion-guardrails", + summary: "Following the confirmation process before removing workflows or agents and their dependencies.", + content: deletionGuardrailsSkill, + }, + { + id: "workflow-run-ops", + title: "Workflow Run Operations", + folder: "workflow-run-ops", + summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", + content: workflowRunOpsSkill, + }, +]; + +const skillEntries = definitions.map((definition) => ({ + ...definition, + catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`, +})); + +const catalogSections = skillEntries.map((entry) => [ + `## ${entry.title}`, + `- **Skill file:** \`${entry.catalogPath}\``, + `- **Use it for:** ${entry.summary}`, +].join("\n")); + +export const skillCatalog = [ + "# Rowboat Skill Catalog", + "", + "Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.", + "", + catalogSections.join("\n\n"), +].join("\n"); + +const normalizeIdentifier = (value: string) => + value.trim().replace(/\\/g, "/").replace(/^\.\/+/, ""); + +const aliasMap = new Map(); + +const registerAlias = (alias: string, entry: ResolvedSkill) => { + const normalized = normalizeIdentifier(alias); + if (!normalized) return; + aliasMap.set(normalized, entry); +}; + +const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { + const normalized = normalizeIdentifier(alias); + if (!normalized) return; + + const variants = new Set([normalized]); + + if (/\.(ts|js)$/i.test(normalized)) { + variants.add(normalized.replace(/\.(ts|js)$/i, "")); + variants.add( + normalized.endsWith(".ts") ? normalized.replace(/\.ts$/i, ".js") : normalized.replace(/\.js$/i, ".ts"), + ); + } else { + variants.add(`${normalized}.ts`); + variants.add(`${normalized}.js`); + } + + for (const variant of variants) { + registerAlias(variant, entry); + } +}; + +for (const entry of skillEntries) { + const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts"); + const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js"); + const resolvedEntry: ResolvedSkill = { + id: entry.id, + catalogPath: entry.catalogPath, + content: entry.content, + }; + + const baseAliases = [ + entry.id, + entry.folder, + `${entry.folder}/skill`, + `${entry.folder}/skill.ts`, + `${entry.folder}/skill.js`, + `skills/${entry.folder}/skill.ts`, + `skills/${entry.folder}/skill.js`, + `${CATALOG_PREFIX}/${entry.folder}/skill.ts`, + `${CATALOG_PREFIX}/${entry.folder}/skill.js`, + absoluteTs, + absoluteJs, + ]; + + for (const alias of baseAliases) { + registerAliasVariants(alias, resolvedEntry); + } +} + +export const availableSkills = skillEntries.map((entry) => entry.id); + +export function resolveSkill(identifier: string): ResolvedSkill | null { + const normalized = normalizeIdentifier(identifier); + if (!normalized) return null; + + return aliasMap.get(normalized) ?? null; +} diff --git a/apps/cli/src/application/assistant/skills/mcp-integration/skill.ts b/apps/cli/src/application/assistant/skills/mcp-integration/skill.ts new file mode 100644 index 00000000..1f3aa313 --- /dev/null +++ b/apps/cli/src/application/assistant/skills/mcp-integration/skill.ts @@ -0,0 +1,60 @@ +export const skill = String.raw` +# MCP Integration Guidance + +Load this skill whenever a user asks about external tools, MCP servers, or how to extend an agent’s capabilities. + +## Key concepts +- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`. +- Agents reference MCP tools through the \`"tools"\` block by specifying \`type\`, \`name\`, \`description\`, \`mcpServerName\`, and a full \`inputSchema\`. +- Tool schemas can include optional property descriptions; only include \`"required"\` when parameters are mandatory. + +## Operator actions +1. Use \`listMcpServers\` to enumerate configured servers. +2. Use \`listMcpTools\` for a server to understand the available operations and schemas. +3. Explain which MCP tools match the user’s needs before editing agent definitions. +4. When adding a tool to an agent, document what it does and ensure the schema mirrors the MCP definition. + +## Example snippets to reference +- Firecrawl search (required param): +\`\`\` +"tools": { + "search": { + "type": "mcp", + "name": "firecrawl_search", + "description": "Search the web", + "mcpServerName": "firecrawl", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "limit": {"type": "number", "description": "Number of results"} + }, + "required": ["query"] + } + } +} +\`\`\` +- ElevenLabs text-to-speech (no required array): +\`\`\` +"tools": { + "text_to_speech": { + "type": "mcp", + "name": "text_to_speech", + "description": "Generate audio from text", + "mcpServerName": "elevenLabs", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"} + } + } + } +} +\`\`\` + +## Safety reminders +- Only recommend MCP tools that are actually configured. +- Clarify any missing details (required parameters, server names) before modifying files. +`; + +export default skill; diff --git a/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts new file mode 100644 index 00000000..416312a8 --- /dev/null +++ b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts @@ -0,0 +1,63 @@ +export const skill = String.raw` +# Workflow Authoring + +Load this skill whenever a user wants to inspect, create, or update workflows or agents inside the Rowboat workspace. + +## Workflow knowledge +- Workflows (\`workflows/*.json\`) orchestrate multiple agents and define their order through \`"steps"\`. +- Agents (\`agents/*.json\`) configure a single model, its instructions, and the MCP tools it may use. +- Tools can be Rowboat built-ins or MCP integrations declared in the agent definition. + +## Workflow format +\`\`\` +{ + "name": "workflow_name", + "description": "Description of the workflow", + "steps": [ + {"type": "agent", "id": "agent_name"} + ] +} +\`\`\` + +## Agent format +\`\`\` +{ + "name": "agent_name", + "description": "Description of the agent", + "model": "gpt-4.1", + "instructions": "Instructions for the agent", + "tools": { + "descriptive_tool_key": { + "type": "mcp", + "name": "actual_mcp_tool_name", + "description": "What the tool does", + "mcpServerName": "server_name_from_config", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "What the parameter means"} + } + } + } + } +} +\`\`\` +- Tool keys should be descriptive (e.g., \`"search"\`, \`"fetch"\`, \`"analyze"\`) rather than the MCP tool name. +- Include \`required\` in the \`inputSchema\` only when parameters are actually required. + +## Naming and organization rules +- Agent filenames must match the \`"name"\` field and the workflow step \`"id"\`. +- Workflow filenames must match the \`"name"\` field. +- Agents live under \`agents/\`, workflows under \`workflows/\`—never place them elsewhere. +- Always keep filenames, \`"name"\`, and referenced ids perfectly aligned. +- Use relative paths (no \${BASE_DIR} prefixes) when calling tools from the CLI. + +## Capabilities checklist +1. Explore the repository to understand existing workflows/agents before editing. +2. Update files carefully to maintain schema validity. +3. Suggest improvements and ask clarifying questions. +4. List and explore MCP servers/tools when users need new capabilities. +5. Confirm work done and outline next steps once changes are complete. +`; + +export default skill; diff --git a/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts b/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts new file mode 100644 index 00000000..74f6d5d2 --- /dev/null +++ b/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts @@ -0,0 +1,61 @@ +export const skill = String.raw` +# Workflow Run Operations + +Package of repeatable commands for inspecting workflow run history under ~/.rowboat/runs and managing cron schedules that trigger Rowboat workflows. Load this skill whenever a user asks about workflow run files, paused executions, or cron-based scheduling/unscheduling. + +## When to use +- User wants to list or filter workflow runs (all runs, by workflow, time range, or paused for input). +- User wants to inspect cron jobs or change the workflow schedule. +- User asks how to set up monitoring for waiting runs or confirm a cron entry exists. + +## Run monitoring examples +Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. + +Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. + +Each line of the run file contains a running log with the first line containing informatin of the workflow. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","workflowId":"exa-search","workflow":{"name":"example_workflow","description":"An example workflow","steps":[{"type":"agent","id":"exa-search"}]},"interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' + +If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. + +1. **List all runs** + + ls ~/.rowboat/runs + + +2. **Filter by workflow** + + grep -rl '"workflowId":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r + + Replace with the desired id. + +3. **Filter by time window** + To the previous commands add the below through unix pipe + + awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"' + + Use the correct timestamps. + +4. **Show runs waiting for human input** + + awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}' + + Prints the files whose last line equals 'pause-for-human-input'. + +## Cron management examples +1. **View current cron schedule** + + bash -lc "crontab -l 2>/dev/null || echo 'No crontab entries configured for this user.'" + +2. **Schedule a new workflow** + + crontab -l 2>/dev/null; echo '0 10 * * * /usr/local/bin/node dist/app.js exa-search "what is the weather in tokyo" >> /Users/arjun/.rowboat/logs/exa_search.log 2>&1' ) | crontab - + + +3. **Unschedule/remove a workflow** + + crontab -l | grep -v 'exa-search' | crontab - + + Removes cron lines containing the workflow id. +`; + +export default skill; diff --git a/apps/cli/src/application/lib/builtin-tools.ts b/apps/cli/src/application/lib/builtin-tools.ts index 9af6a9fe..2d98f561 100644 --- a/apps/cli/src/application/lib/builtin-tools.ts +++ b/apps/cli/src/application/lib/builtin-tools.ts @@ -7,6 +7,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client"; +import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; const BuiltinToolsSchema = z.record(z.string(), z.object({ description: z.string(), @@ -18,6 +19,30 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ })); export const BuiltinTools: z.infer = { + loadSkill: { + description: "Load a Rowboat skill definition into context by fetching its guidance string", + inputSchema: z.object({ + skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"), + }), + execute: async ({ skillName }: { skillName: string }) => { + const resolved = resolveSkill(skillName); + + if (!resolved) { + return { + success: false, + message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`, + }; + } + + return { + success: true, + skillName: resolved.id, + path: resolved.catalogPath, + content: resolved.content, + }; + }, + }, + exploreDirectory: { description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', inputSchema: z.object({ @@ -421,4 +446,4 @@ export const BuiltinTools: z.infer = { } }, }, -}; \ No newline at end of file +}; From 92b702d0399d06176d59214e37e1f14e3f2f6d29 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:21:41 +0530 Subject: [PATCH 24/38] log start event w/ agent data --- .../src/application/entities/run-events.ts | 3 +-- apps/cli/src/application/lib/agent.ts | 25 +++++++++++++------ .../src/application/lib/stream-renderer.ts | 10 ++++---- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/application/entities/run-events.ts b/apps/cli/src/application/entities/run-events.ts index 47f27ce9..670b93e3 100644 --- a/apps/cli/src/application/entities/run-events.ts +++ b/apps/cli/src/application/entities/run-events.ts @@ -10,8 +10,7 @@ const BaseRunEvent = z.object({ export const RunStartEvent = BaseRunEvent.extend({ type: z.literal("start"), runId: z.string(), - agentId: z.string(), - agent: Agent, + agent: z.string(), interactive: z.boolean(), }); diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 4881e2b4..1a612370 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -223,16 +223,17 @@ export async function* streamAgent(opts: { runId?: string; input?: string; interactive?: boolean; -}) { +}): AsyncGenerator, void, unknown> { const messages: z.infer = []; // load existing and assemble state if required - if (opts.runId) { - console.error("loading run", opts.runId); + let runId = opts.runId; + if (runId) { + console.error("loading run", runId); let stream: fs.ReadStream | null = null; let rl: Interface | null = null; try { - const logFile = path.join(WorkDir, "runs", `${opts.runId}.jsonl`); + const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); stream = fs.createReadStream(logFile, { encoding: "utf8" }); rl = createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { @@ -253,8 +254,8 @@ export async function* streamAgent(opts: { } // create runId if not present - if (!opts.runId) { - opts.runId = runIdGenerator.next(); + if (!runId) { + runId = runIdGenerator.next(); } // load agent data @@ -280,11 +281,21 @@ export async function* streamAgent(opts: { } // set up - const logger = new RunLogger(opts.runId); + const logger = new RunLogger(runId); const ly = new LogAndYield(logger); const provider = getProvider(agent.provider); const model = provider(agent.model || ModelConfig.defaults.model); + // emit start event if first time run + if (!opts.runId) { + yield* ly.logAndYield({ + type: "start", + runId, + agent: opts.agent, + interactive: opts.interactive ?? false, + }); + } + // get first input if needed let rl: Interface | null = null; if (opts.interactive) { diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index 7ac3279d..cfd7ab1a 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -27,7 +27,7 @@ export class StreamRenderer { render(event: z.infer) { switch (event.type) { case "start": { - this.onStart(event.agentId, event.runId, event.interactive); + this.onStart(event.agent, event.runId, event.interactive); break; } case "step-start": { @@ -94,19 +94,19 @@ export class StreamRenderer { } } - private onStart(workflowId: string, runId: string, interactive: boolean) { + private onStart(agent: string, runId: string, interactive: boolean) { this.write("\n"); - this.write(this.bold(`▶ Workflow ${workflowId} (run ${runId})`)); + this.write(this.bold(`▶ Agent ${agent} (run ${runId})`)); if (!interactive) this.write(this.dim(" (--no-interactive)")); this.write("\n"); } private onEnd() { - this.write(this.bold("\n■ Workflow complete\n")); + this.write(this.bold("\n■ complete\n")); } private onError(error: string) { - this.write(this.red(`\n✖ Workflow error: ${error}\n`)); + this.write(this.red(`\n✖ error: ${error}\n`)); } private onStepStart() { From a76cb6089c0d91be9a680964215af038760c7edc Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 16 Nov 2025 20:58:31 +0530 Subject: [PATCH 25/38] refactor agent execution --- apps/cli/bin/app.js | 6 + apps/cli/src/app.ts | 134 +++++++- .../src/application/entities/run-events.ts | 1 - apps/cli/src/application/lib/agent.ts | 287 +++++------------- apps/cli/src/application/lib/exec-tool.ts | 13 +- .../src/application/lib/stream-renderer.ts | 5 +- 6 files changed, 224 insertions(+), 222 deletions(-) diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js index 172d56ed..457f2ab7 100755 --- a/apps/cli/bin/app.js +++ b/apps/cli/bin/app.js @@ -20,12 +20,18 @@ yargs(hideBin(process.argv)) .option("input", { type: "string", description: "The input to the agent", + }) + .option("no-interactive", { + type: "boolean", + description: "Do not interact with the user", + default: false, }), (argv) => { app({ agent: argv.agent, runId: argv.run_id, input: argv.input, + noInteractive: argv.noInteractive, }); } ) diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 061bdcb2..eb00e35d 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -1,19 +1,137 @@ -import { streamAgent } from "./application/lib/agent.js"; +import { loadAgent, RunLogger, streamAgentTurn } from "./application/lib/agent.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; +import { stdin as input, stdout as output } from "node:process"; +import fs from "fs"; +import path from "path"; +import { WorkDir } from "./application/config/config.js"; +import { RunEvent, RunStartEvent } from "./application/entities/run-events.js"; +import { createInterface, Interface } from "node:readline/promises"; +import { runIdGenerator } from "./application/lib/run-id-gen.js"; +import { Agent } from "./application/entities/agent.js"; +import { MessageList } from "./application/entities/message.js"; +import { z } from "zod"; +import { CopilotAgent } from "./application/assistant/agent.js"; export async function app(opts: { agent: string; runId?: string; input?: string; + noInteractive?: boolean; }) { + let inputCount = 0; + const messages: z.infer = []; const renderer = new StreamRenderer(); - for await (const event of streamAgent({ - ...opts, - interactive: true, - })) { - renderer.render(event); - if (event?.type === "error") { - process.exitCode = 1; + + // load existing and assemble state if required + let runId = opts.runId; + if (runId) { + console.error("loading run", runId); + let stream: fs.ReadStream | null = null; + let rl: Interface | null = null; + try { + const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); + stream = fs.createReadStream(logFile, { encoding: "utf8" }); + rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (line.trim() === "") { + continue; + } + const parsed = JSON.parse(line); + const event = RunEvent.parse(parsed); + switch (event.type) { + case "message": + messages.push(event.message); + break; + } + } + } finally { + stream?.close(); } } + + // add user input + if (opts.input) { + messages.push({ + role: "user", + content: opts.input, + }); + inputCount++; + } + + // create runId if not present + if (!runId) { + runId = runIdGenerator.next(); + } + const logger = new RunLogger(runId); + + // load agent data + let agent: z.infer | null = null; + if (opts.agent === "copilot") { + agent = CopilotAgent; + } else { + agent = await loadAgent(opts.agent); + } + if (!agent) { + throw new Error("unable to load agent"); + } + + // emit start event if first time run + if (!opts.runId) { + const ev = { + type: "start", + runId, + agent: agent.name, + } as z.infer; + logger.log(ev); + renderer.render(ev); + } + + // loop between user and agent + let rl: Interface | null = null; + if (!opts.noInteractive) { + rl = createInterface({ input, output }); + } + let firstPass = true; + try { + while (true) { + let askInput = false; + if (firstPass) { + if (!opts.input) { + askInput = true; + } + firstPass = false; + } else { + askInput = true; + } + if (rl && askInput) { + const userInput = await rl.question("You: "); + if (["quit", "exit", "q"].includes(userInput.trim().toLowerCase())) { + console.error("Bye!"); + return; + } + inputCount++; + messages.push({ + role: "user", + content: userInput, + }); + } + for await (const event of streamAgentTurn({ + agent, + messages, + })) { + logger.log(event); + renderer.render(event); + if (event?.type === "error") { + process.exitCode = 1; + } + } + + if (opts.noInteractive) { + break; + } + } + } finally { + logger.close(); + rl?.close(); + } } \ No newline at end of file diff --git a/apps/cli/src/application/entities/run-events.ts b/apps/cli/src/application/entities/run-events.ts index 670b93e3..1ce6a7a6 100644 --- a/apps/cli/src/application/entities/run-events.ts +++ b/apps/cli/src/application/entities/run-events.ts @@ -11,7 +11,6 @@ export const RunStartEvent = BaseRunEvent.extend({ type: z.literal("start"), runId: z.string(), agent: z.string(), - interactive: z.boolean(), }); export const RunStepStartEvent = BaseRunEvent.extend({ diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 1a612370..3661acaf 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -4,7 +4,6 @@ import path from "path"; import { ModelConfig, WorkDir } from "../config/config.js"; import { Agent, ToolAttachment } from "../entities/agent.js"; import { createInterface, Interface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; import { AssistantContentPart, AssistantMessage, Message, MessageList, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js"; import { runIdGenerator } from "./run-id-gen.js"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; @@ -79,23 +78,6 @@ export class RunLogger { } } -export class LogAndYield { - private logger: RunLogger - - constructor(logger: RunLogger) { - this.logger = logger; - } - - async *logAndYield(event: z.infer): AsyncGenerator, void, unknown> { - const ev = { - ...event, - ts: new Date().toISOString(), - } - this.logger.log(ev); - yield ev; - } -} - export class StreamStepMessageBuilder { private parts: z.infer[] = []; private textBuffer: string = ""; @@ -218,56 +200,11 @@ export function convertFromMessages(messages: z.infer[]): ModelM } -export async function* streamAgent(opts: { - agent: string; - runId?: string; - input?: string; - interactive?: boolean; +export async function* streamAgentTurn(opts: { + agent: z.infer; + messages: z.infer; }): AsyncGenerator, void, unknown> { - const messages: z.infer = []; - - // load existing and assemble state if required - let runId = opts.runId; - if (runId) { - console.error("loading run", runId); - let stream: fs.ReadStream | null = null; - let rl: Interface | null = null; - try { - const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`); - stream = fs.createReadStream(logFile, { encoding: "utf8" }); - rl = createInterface({ input: stream, crlfDelay: Infinity }); - for await (const line of rl) { - if (line.trim() === "") { - continue; - } - const parsed = JSON.parse(line); - const event = RunEvent.parse(parsed); - switch (event.type) { - case "message": - messages.push(event.message); - break; - } - } - } finally { - stream?.close(); - } - } - - // create runId if not present - if (!runId) { - runId = runIdGenerator.next(); - } - - // load agent data - let agent: z.infer | null = null; - if (opts.agent === "copilot") { - agent = CopilotAgent; - } else { - agent = await loadAgent(opts.agent); - } - if (!agent) { - throw new Error("unable to load agent"); - } + const { agent, messages } = opts; // set up tools const tools: ToolSet = {}; @@ -281,149 +218,87 @@ export async function* streamAgent(opts: { } // set up - const logger = new RunLogger(runId); - const ly = new LogAndYield(logger); const provider = getProvider(agent.provider); const model = provider(agent.model || ModelConfig.defaults.model); - // emit start event if first time run - if (!opts.runId) { - yield* ly.logAndYield({ - type: "start", - runId, - agent: opts.agent, - interactive: opts.interactive ?? false, - }); - } - - // get first input if needed - let rl: Interface | null = null; - if (opts.interactive) { - rl = createInterface({ input, output }); - } - if (opts.input) { - const m: z.infer = { - role: "user", - content: opts.input, - }; - messages.push(m); - yield *ly.logAndYield({ - type: "message", - message: m, - }); - } - try { - // loop b/w user and agent - while (true) { - // get input in interactive mode when last message is not user - if (opts.interactive && (messages.length === 0 || messages[messages.length - 1].role !== "user")) { - const input = await rl!.question("You: "); - // Exit condition - if (["q", "quit", "exit"].includes(input.toLowerCase())) { - console.log("\n👋 Goodbye!"); - return; - } - - const m: z.infer = { - role: "user", - content: input, - }; - messages.push(m); - yield* ly.logAndYield({ - type: "message", - message: m, - }); - } - - // inner loop to handle tool calls - while (true) { - // stream agent response and build message - const messageBuilder = new StreamStepMessageBuilder(); - for await (const event of streamLlm( - model, - messages, - agent.instructions, - tools, - )) { - messageBuilder.ingest(event); - yield* ly.logAndYield({ - type: "stream-event", - event: event, - }); - } - - // build and emit final message from agent response - const msg = messageBuilder.get(); - messages.push(msg); - yield* ly.logAndYield({ - type: "message", - message: msg, - }); - - // handle tool calls - const mappedToolCalls: z.infer[] = []; - let msgToolCallParts: z.infer[] = []; - if (msg.content instanceof Array) { - msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); - } - const hasToolCalls = msgToolCallParts.length > 0; - console.log(msgToolCallParts); - - // validate and map tool calls - for (const part of msgToolCallParts) { - const agentTool = tools[part.toolName]; - if (!agentTool) { - throw new Error(`Tool ${part.toolName} not found`); - } - mappedToolCalls.push({ - toolCall: part, - agentTool: agent.tools![part.toolName], - }); - } - - for (const call of mappedToolCalls) { - const { agentTool, toolCall } = call; - yield* ly.logAndYield({ - type: "tool-invocation", - toolName: toolCall.toolName, - input: JSON.stringify(toolCall.arguments), - }); - const result = await execTool(agentTool, toolCall.arguments); - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(result), - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - }; - messages.push(resultMsg); - yield* ly.logAndYield({ - type: "tool-result", - toolName: toolCall.toolName, - result: result, - }); - yield* ly.logAndYield({ - type: "message", - message: resultMsg, - }); - } - - // if the agent response had tool calls, replay this agent - if (hasToolCalls) { - continue; - } - - // otherwise, break - break; - } - - // if not interactive, return - if (!opts.interactive) { - break; - } + // run one turn + while (true) { + // stream agent response and build message + const messageBuilder = new StreamStepMessageBuilder(); + for await (const event of streamLlm( + model, + messages, + agent.instructions, + tools, + )) { + messageBuilder.ingest(event); + yield { + type: "stream-event", + event: event, + }; } - } finally { - rl?.close(); - logger.close(); + + // build and emit final message from agent response + const msg = messageBuilder.get(); + messages.push(msg); + yield { + type: "message", + message: msg, + }; + + // handle tool calls + const mappedToolCalls: z.infer[] = []; + let msgToolCallParts: z.infer[] = []; + if (msg.content instanceof Array) { + msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); + } + const hasToolCalls = msgToolCallParts.length > 0; + + // validate and map tool calls + for (const part of msgToolCallParts) { + const agentTool = tools[part.toolName]; + if (!agentTool) { + throw new Error(`Tool ${part.toolName} not found`); + } + mappedToolCalls.push({ + toolCall: part, + agentTool: agent.tools![part.toolName], + }); + } + + for (const call of mappedToolCalls) { + const { agentTool, toolCall } = call; + yield { + type: "tool-invocation", + toolName: toolCall.toolName, + input: JSON.stringify(toolCall.arguments), + }; + const result = await execTool(agentTool, toolCall.arguments); + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; + messages.push(resultMsg); + yield { + type: "tool-result", + toolName: toolCall.toolName, + result: result, + }; + yield { + type: "message", + message: resultMsg, + }; + } + + // if the agent response had tool calls, replay this agent + if (hasToolCalls) { + continue; + } + + // otherwise, break + break; } } diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index d0409365..3c78e619 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -8,7 +8,8 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Client } from "@modelcontextprotocol/sdk/client"; import { AssistantMessage } from "../entities/message.js"; import { BuiltinTools } from "./builtin-tools.js"; -import { streamAgent } from "./agent.js"; +import { loadAgent, streamAgentTurn } from "./agent.js"; +import { app } from "@/app.js"; async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { // load mcp configuration from the tool @@ -55,9 +56,13 @@ async function execMcpTool(agentTool: z.infer & { type: " async function execAgentTool(agentTool: z.infer & { type: "agent" }, input: any): Promise { let lastMsg: z.infer | null = null; - for await (const event of streamAgent({ - agent: agentTool.name, - input: JSON.stringify(input), + const agent = await loadAgent(agentTool.name); + for await (const event of streamAgentTurn({ + agent, + messages: [{ + role: "user", + content: JSON.stringify(input), + }], })) { if (event.type === "message" && event.message.role === "assistant") { lastMsg = event.message; diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index cfd7ab1a..341be492 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -27,7 +27,7 @@ export class StreamRenderer { render(event: z.infer) { switch (event.type) { case "start": { - this.onStart(event.agent, event.runId, event.interactive); + this.onStart(event.agent, event.runId); break; } case "step-start": { @@ -94,10 +94,9 @@ export class StreamRenderer { } } - private onStart(agent: string, runId: string, interactive: boolean) { + private onStart(agent: string, runId: string) { this.write("\n"); this.write(this.bold(`▶ Agent ${agent} (run ${runId})`)); - if (!interactive) this.write(this.dim(" (--no-interactive)")); this.write("\n"); } From 391a0c97f1a505eb6f91b94a4c75081b1ad62717 Mon Sep 17 00:00:00 2001 From: arkml Date: Mon, 17 Nov 2025 21:58:07 +0530 Subject: [PATCH 26/38] Revise README to reflect new features and branding Updated project description and features in README. --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f3a1f0e..529cdefc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![ui](/assets/banner.png) -

Let AI build you coworkers

+

Claude Code for Everything Else

@@ -35,11 +35,14 @@

-

-⚡ Build AI agents instantly with natural language | 🔌 Connect tools with one-click integrations | 📂 Power with knowledge by adding documents for RAG | 🔄 Automate workflows by setting up triggers and actions | 🚀 Deploy anywhere via API or SDK

-☁️ Prefer a hosted version? Use our cloud to starting building agents right away! -

+- ✨ **Create background agents with full shell access** + - E.g. "Generate a NotebookLM-style podcast from my saved articles every morning" +- 🔧 **Connect any MCP server to add capabilities** + - Add MCP servers and RowboatX handles the integration +- 🎯 **Control agents with standard Unix commands** + - RowboatX uses bash to spawn, monitor, and manage all background agents +With full terminal access and filesystem-as-state architecture, RowboatX gives you complete control over powerful AI automations! ## Quick start 1. Set your OpenAI key From 8490fa26b3bc4256cb0d8c17c2dec67522276458 Mon Sep 17 00:00:00 2001 From: arkml Date: Mon, 17 Nov 2025 22:08:40 +0530 Subject: [PATCH 27/38] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 529cdefc..a764f107 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![ui](/assets/banner.png) -

Claude Code for Everything Else

+

CLI Tool for Background Agents

@@ -35,6 +35,7 @@

+ - ✨ **Create background agents with full shell access** - E.g. "Generate a NotebookLM-style podcast from my saved articles every morning" - 🔧 **Connect any MCP server to add capabilities** From 83fc9e36f5c5c422f133b1cfd515dd5f2c22e848 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 17 Nov 2025 23:27:00 +0530 Subject: [PATCH 28/38] Better Updated Promting --- apps/cli/examples/notebooklm-podcast.json | 8 +- apps/cli/src/application/assistant/agent.ts | 2 +- .../assistant/skills/builtin-tools/skill.ts | 179 ++++++++++++++++++ .../src/application/assistant/skills/index.ts | 8 + .../skills/workflow-authoring/skill.ts | 163 +++++++++++++--- .../skills/workflow-run-ops/skill.ts | 76 ++++++-- apps/cli/src/application/lib/builtin-tools.ts | 38 ++-- 7 files changed, 404 insertions(+), 70 deletions(-) create mode 100644 apps/cli/src/application/assistant/skills/builtin-tools/skill.ts diff --git a/apps/cli/examples/notebooklm-podcast.json b/apps/cli/examples/notebooklm-podcast.json index 9b957736..616546f2 100644 --- a/apps/cli/examples/notebooklm-podcast.json +++ b/apps/cli/examples/notebooklm-podcast.json @@ -40,7 +40,7 @@ "tools": { "bash": { "type": "builtin", - "name": "bash" + "name": "executeCommand" } } } @@ -53,7 +53,7 @@ "tools": { "bash": { "type": "builtin", - "name": "bash" + "name": "executeCommand" } } } @@ -65,7 +65,7 @@ "instructions": "Your job is to pick 2 interesting papers and related papers on the same topic, and then summarise each of them inidivually using the right tool calls. Make sure to pass in the URL of the paper to the summaurse tool. Don't ask for human input.", "tools": { "summariser": { - "type": "workflow", + "type": "agent", "name": "summariser_workflow" } } @@ -109,7 +109,7 @@ }, "bash": { "type": "builtin", - "name": "bash" + "name": "executeCommand" } } } diff --git a/apps/cli/src/application/assistant/agent.ts b/apps/cli/src/application/assistant/agent.ts index d06d85f2..cb95e0fe 100644 --- a/apps/cli/src/application/assistant/agent.ts +++ b/apps/cli/src/application/assistant/agent.ts @@ -15,6 +15,6 @@ export const CopilotAgent: z.infer = { name: "rowboatx", description: "Rowboatx copilot", instructions: CopilotInstructions, - model: "gpt-4.1", + model: "gpt-5.1", tools, } \ No newline at end of file diff --git a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts new file mode 100644 index 00000000..1d7f26f3 --- /dev/null +++ b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts @@ -0,0 +1,179 @@ +export const skill = String.raw` +# Builtin Tools Reference + +Load this skill when creating or modifying agents that need access to Rowboat's builtin tools (shell execution, file operations, etc.). + +## Available Builtin Tools + +Agents can use builtin tools by declaring them in the \`"tools"\` object with \`"type": "builtin"\` and the appropriate \`"name"\`. + +### executeCommand +**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. + +**Agent tool declaration:** +\`\`\`json +"tools": { + "bash": { + "type": "builtin", + "name": "executeCommand" + } +} +\`\`\` + +**What it can do:** +- Run package managers (npm, pip, apt, brew, cargo, go get, etc.) +- Git operations (clone, commit, push, pull, status, diff, log, etc.) +- System operations (ps, top, df, du, find, grep, kill, etc.) +- Build and compilation (make, cargo build, go build, npm run build, etc.) +- Network operations (curl, wget, ping, ssh, netstat, etc.) +- Text processing (awk, sed, grep, jq, yq, cut, sort, uniq, etc.) +- Database operations (psql, mysql, mongo, redis-cli, etc.) +- Container operations (docker, kubectl, podman, etc.) +- Testing and debugging (pytest, jest, cargo test, etc.) +- File operations (cat, head, tail, wc, diff, patch, etc.) +- Any CLI tool or script execution + +**Agent instruction examples:** +- "Use the bash tool to run git commands for version control operations" +- "Execute curl commands using the bash tool to fetch data from APIs" +- "Use bash to run 'npm install' and 'npm test' commands" +- "Run Python scripts using the bash tool with 'python script.py'" +- "Use bash to execute 'docker ps' and inspect container status" +- "Run database queries using 'psql' or 'mysql' commands via bash" +- "Use bash to execute system monitoring commands like 'top' or 'ps aux'" + +**Pro tips for agent instructions:** +- Commands can be chained with && for sequential execution +- Use pipes (|) to combine Unix tools (e.g., "cat file.txt | grep pattern | wc -l") +- Redirect output with > or >> when needed +- Full bash shell features are available (variables, loops, conditionals, etc.) +- Tools like jq, yq, awk, sed can parse and transform data + +**Example agent with executeCommand:** +\`\`\`json +{ + "name": "arxiv-feed-reader", + "description": "A feed reader for the arXiv", + "model": "gpt-4.1", + "instructions": "Extract latest papers from the arXiv feed and summarize them. Use curl to fetch the RSS feed, then parse it with yq and jq:\n\ncurl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"'\n\nThis will give you papers containing 'agent' in the title.", + "tools": { + "bash": { + "type": "builtin", + "name": "executeCommand" + } + } +} +\`\`\` + +**Another example - System monitoring agent:** +\`\`\`json +{ + "name": "system-monitor", + "description": "Monitor system resources and processes", + "model": "gpt-4.1", + "instructions": "Monitor system resources using bash commands. Use 'df -h' for disk usage, 'free -h' for memory, 'top -bn1' for processes, 'ps aux' for process list. Parse the output and report any issues.", + "tools": { + "bash": { + "type": "builtin", + "name": "executeCommand" + } + } +} +\`\`\` + +**Another example - Git automation agent:** +\`\`\`json +{ + "name": "git-helper", + "description": "Automate git operations", + "model": "gpt-4.1", + "instructions": "Help with git operations. Use commands like 'git status', 'git log --oneline -10', 'git diff', 'git branch -a' to inspect the repository. Can also run 'git add', 'git commit', 'git push' when instructed.", + "tools": { + "bash": { + "type": "builtin", + "name": "executeCommand" + } + } +} +\`\`\` + +## Agent-to-Agent Calling + +Agents can call other agents as tools to create complex multi-step workflows. This is the core mechanism for building multi-agent systems in the CLI. + +**Tool declaration:** +\`\`\`json +"tools": { + "summariser": { + "type": "agent", + "name": "summariser_agent" + } +} +\`\`\` + +**When to use:** +- Breaking complex tasks into specialized sub-agents +- Creating reusable agent components +- Orchestrating multi-step workflows +- Delegating specialized tasks (e.g., summarization, data processing, audio generation) + +**How it works:** +- The agent calls the tool like any other tool +- The target agent receives the input and processes it +- Results are returned as tool output +- The calling agent can then continue processing or delegate further + +**Example - Agent that delegates to a summarizer:** +\`\`\`json +{ + "name": "paper_analyzer", + "model": "gpt-4.1", + "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the summariser. Don't ask for human input.", + "tools": { + "summariser": { + "type": "agent", + "name": "summariser_agent" + } + } +} +\`\`\` + +**Tips for agent chaining:** +- Make instructions explicit about when to call other agents +- Pass clear, structured data between agents +- Add "Don't ask for human input" for autonomous workflows +- Keep each agent focused on a single responsibility + +## Additional Builtin Tools + +While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`. + +## Best Practices + +1. **Give agents clear examples** in their instructions showing exact bash commands to run +2. **Explain output parsing** - show how to use jq, yq, grep, awk to extract data +3. **Chain commands efficiently** - use && for sequences, | for pipes +4. **Handle errors** - remind agents to check exit codes and stderr +5. **Be specific** - provide example commands rather than generic descriptions +6. **Security** - remind agents to validate inputs and avoid dangerous operations + +## When to Use Builtin Tools vs MCP Tools vs Agent Tools + +- **Use builtin executeCommand** when you need: CLI tools, system operations, data processing, git operations, any shell command +- **Use MCP tools** when you need: Web scraping (firecrawl), text-to-speech (elevenlabs), specialized APIs, external service integrations +- **Use agent tools (\`"type": "agent"\`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning + +Many tasks can be accomplished with just \`executeCommand\` and common Unix tools - it's incredibly powerful! + +## Key Insight: Multi-Agent Workflows + +In the CLI, multi-agent workflows are built by: +1. Creating specialized agents for specific tasks (in \`agents/\` directory) +2. Creating an orchestrator agent that has other agents in its \`tools\` +3. Running the orchestrator with \`rowboatx --agent orchestrator_name\` + +There are no separate "workflow" files - everything is an agent! +`; + +export default skill; + diff --git a/apps/cli/src/application/assistant/skills/index.ts b/apps/cli/src/application/assistant/skills/index.ts index 17210983..3d0f5fc5 100644 --- a/apps/cli/src/application/assistant/skills/index.ts +++ b/apps/cli/src/application/assistant/skills/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; @@ -31,6 +32,13 @@ const definitions: SkillDefinition[] = [ summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", content: workflowAuthoringSkill, }, + { + id: "builtin-tools", + title: "Builtin Tools Reference", + folder: "builtin-tools", + summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.", + content: builtinToolsSkill, + }, { id: "mcp-integration", title: "MCP Integration Guidance", diff --git a/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts index 416312a8..a710d1f5 100644 --- a/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts +++ b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts @@ -1,26 +1,26 @@ export const skill = String.raw` -# Workflow Authoring +# Agent and Workflow Authoring -Load this skill whenever a user wants to inspect, create, or update workflows or agents inside the Rowboat workspace. +Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace. -## Workflow knowledge -- Workflows (\`workflows/*.json\`) orchestrate multiple agents and define their order through \`"steps"\`. -- Agents (\`agents/*.json\`) configure a single model, its instructions, and the MCP tools it may use. -- Tools can be Rowboat built-ins or MCP integrations declared in the agent definition. +## Core Concepts -## Workflow format -\`\`\` -{ - "name": "workflow_name", - "description": "Description of the workflow", - "steps": [ - {"type": "agent", "id": "agent_name"} - ] -} -\`\`\` +**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. + +- **All definitions live in \`agents/*.json\`** - there is no separate workflows folder +- Agents configure a model, instructions, and the tools they can use +- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents** +- **"Workflows" are just agents that orchestrate other agents** by having them as tools + +## How multi-agent workflows work + +1. **Create an orchestrator agent** that has other agents in its \`tools\` +2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\` +3. The orchestrator calls other agents as tools when needed +4. Data flows through tool call parameters and responses ## Agent format -\`\`\` +\`\`\`json { "name": "agent_name", "description": "Description of the agent", @@ -42,22 +42,127 @@ Load this skill whenever a user wants to inspect, create, or update workflows or } } \`\`\` -- Tool keys should be descriptive (e.g., \`"search"\`, \`"fetch"\`, \`"analyze"\`) rather than the MCP tool name. -- Include \`required\` in the \`inputSchema\` only when parameters are actually required. + +## Tool types + +### Builtin tools +\`\`\`json +"bash": { + "type": "builtin", + "name": "executeCommand" +} +\`\`\` + +### MCP tools +\`\`\`json +"search": { + "type": "mcp", + "name": "firecrawl_search", + "description": "Search the web", + "mcpServerName": "firecrawl", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"] + } +} +\`\`\` + +### Agent tools (for chaining agents) +\`\`\`json +"summariser": { + "type": "agent", + "name": "summariser_agent" +} +\`\`\` +- Use \`"type": "agent"\` to call other agents as tools +- The target agent will be invoked with the parameters you pass +- Results are returned as tool output +- This is how you build multi-agent workflows + +## Complete Multi-Agent Workflow Example + +**Podcast creation workflow** - This is all done through agents calling other agents: + +**1. Task-specific agent** (does one thing): +\`\`\`json +{ + "name": "summariser_agent", + "description": "Summarises an arxiv paper", + "model": "gpt-4.1", + "instructions": "Download and summarise an arxiv paper. Use curl to fetch the PDF. Output just the GIST in two lines. Don't ask for human input.", + "tools": { + "bash": {"type": "builtin", "name": "executeCommand"} + } +} +\`\`\` + +**2. Agent that delegates to other agents**: +\`\`\`json +{ + "name": "summarise-a-few", + "description": "Summarises multiple arxiv papers", + "model": "gpt-4.1", + "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the tool. Don't ask for human input.", + "tools": { + "summariser": { + "type": "agent", + "name": "summariser_agent" + } + } +} +\`\`\` + +**3. Orchestrator agent** (coordinates the whole workflow): +\`\`\`json +{ + "name": "podcast_workflow", + "description": "Create a podcast from arXiv papers", + "model": "gpt-4.1", + "instructions": "1. Fetch arXiv papers about agents using bash\n2. Pick papers and summarise them using summarise_papers\n3. Create a podcast transcript\n4. Generate audio using text_to_speech\n\nExecute these steps in sequence.", + "tools": { + "bash": {"type": "builtin", "name": "executeCommand"}, + "summarise_papers": { + "type": "agent", + "name": "summarise-a-few" + }, + "text_to_speech": { + "type": "mcp", + "name": "text_to_speech", + "mcpServerName": "elevenLabs", + "description": "Generate audio", + "inputSchema": { "type": "object", "properties": {...}} + } + } +} +\`\`\` + +**To run this workflow**: \`rowboatx --agent podcast_workflow\` ## Naming and organization rules -- Agent filenames must match the \`"name"\` field and the workflow step \`"id"\`. -- Workflow filenames must match the \`"name"\` field. -- Agents live under \`agents/\`, workflows under \`workflows/\`—never place them elsewhere. -- Always keep filenames, \`"name"\`, and referenced ids perfectly aligned. -- Use relative paths (no \${BASE_DIR} prefixes) when calling tools from the CLI. +- **All agents live in \`agents/*.json\`** - no other location +- Agent filenames must match the \`"name"\` field exactly +- When referencing an agent as a tool, use its \`"name"\` value +- Always keep filenames and \`"name"\` fields perfectly aligned +- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users + +## Best practices for multi-agent design +1. **Single responsibility**: Each agent should do one specific thing well +2. **Clear delegation**: Agent instructions should explicitly say when to call other agents +3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows +4. **Data passing**: Make it clear what data to extract and pass between agents +5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") +6. **Orchestration**: Create a top-level agent that coordinates the workflow ## Capabilities checklist -1. Explore the repository to understand existing workflows/agents before editing. -2. Update files carefully to maintain schema validity. -3. Suggest improvements and ask clarifying questions. -4. List and explore MCP servers/tools when users need new capabilities. -5. Confirm work done and outline next steps once changes are complete. +1. Explore \`agents/\` directory to understand existing agents before editing +2. Update files carefully to maintain schema validity +3. When creating multi-agent workflows, create an orchestrator agent +4. Add other agents as tools with \`"type": "agent"\` for chaining +5. List and explore MCP servers/tools when users need new capabilities +6. Confirm work done and outline next steps once changes are complete `; export default skill; diff --git a/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts b/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts index 74f6d5d2..25f62267 100644 --- a/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts +++ b/apps/cli/src/application/assistant/skills/workflow-run-ops/skill.ts @@ -1,19 +1,39 @@ export const skill = String.raw` -# Workflow Run Operations +# Agent Run Operations -Package of repeatable commands for inspecting workflow run history under ~/.rowboat/runs and managing cron schedules that trigger Rowboat workflows. Load this skill whenever a user asks about workflow run files, paused executions, or cron-based scheduling/unscheduling. +Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling. ## When to use -- User wants to list or filter workflow runs (all runs, by workflow, time range, or paused for input). -- User wants to inspect cron jobs or change the workflow schedule. -- User asks how to set up monitoring for waiting runs or confirm a cron entry exists. +- User wants to run an agent (including multi-agent workflows) +- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input) +- User wants to inspect cron jobs or change agent schedules +- User asks how to set up monitoring for waiting runs + +## Running Agents + +**To run any agent**: +\`\`\`bash +rowboatx --agent +\`\`\` + +**With input**: +\`\`\`bash +rowboatx --agent --input "your input here" +\`\`\` + +**Non-interactive** (for automation/cron): +\`\`\`bash +rowboatx --agent --input "input" --no-interactive +\`\`\` + +**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow. ## Run monitoring examples Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. -Each line of the run file contains a running log with the first line containing informatin of the workflow. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","workflowId":"exa-search","workflow":{"name":"example_workflow","description":"An example workflow","steps":[{"type":"agent","id":"exa-search"}]},"interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' +Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. @@ -22,11 +42,11 @@ If a run is waiting for human input the last line will contain 'paused_for_human ls ~/.rowboat/runs -2. **Filter by workflow** +2. **Filter by agent** - grep -rl '"workflowId":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r + grep -rl '"agent":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r - Replace with the desired id. + Replace with the desired agent name. 3. **Filter by time window** To the previous commands add the below through unix pipe @@ -42,20 +62,34 @@ If a run is waiting for human input the last line will contain 'paused_for_human Prints the files whose last line equals 'pause-for-human-input'. ## Cron management examples -1. **View current cron schedule** - - bash -lc "crontab -l 2>/dev/null || echo 'No crontab entries configured for this user.'" - -2. **Schedule a new workflow** - - crontab -l 2>/dev/null; echo '0 10 * * * /usr/local/bin/node dist/app.js exa-search "what is the weather in tokyo" >> /Users/arjun/.rowboat/logs/exa_search.log 2>&1' ) | crontab - - -3. **Unschedule/remove a workflow** +For scheduling agents to run automatically at specific times. + +1. **View current cron schedule** + \`\`\`bash + crontab -l 2>/dev/null || echo 'No crontab entries configured.' + \`\`\` + +2. **Schedule an agent to run periodically** + \`\`\`bash + (crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent --input "input" --no-interactive >> ~/.rowboat/logs/.log 2>&1') | crontab - + \`\`\` - crontab -l | grep -v 'exa-search' | crontab - - - Removes cron lines containing the workflow id. + Example (runs daily at 10 AM): + \`\`\`bash + (crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab - + \`\`\` + +3. **Unschedule/remove an agent** + \`\`\`bash + crontab -l | grep -v '' | crontab - + \`\`\` + +## Common cron schedule patterns +- \`0 10 * * *\` - Daily at 10 AM +- \`0 */6 * * *\` - Every 6 hours +- \`0 9 * * 1\` - Every Monday at 9 AM +- \`*/30 * * * *\` - Every 30 minutes `; export default skill; diff --git a/apps/cli/src/application/lib/builtin-tools.ts b/apps/cli/src/application/lib/builtin-tools.ts index 2d98f561..5c4ae64b 100644 --- a/apps/cli/src/application/lib/builtin-tools.ts +++ b/apps/cli/src/application/lib/builtin-tools.ts @@ -44,7 +44,7 @@ export const BuiltinTools: z.infer = { }, exploreDirectory: { - description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization', + description: 'Recursively explore directory structure to understand existing agents and file organization', inputSchema: z.object({ subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'), maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'), @@ -260,27 +260,35 @@ export const BuiltinTools: z.infer = { }, }, - analyzeWorkflow: { - description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies', + analyzeAgent: { + description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ - workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'), + agentName: z.string().describe('Name of the agent file to analyze (with or without .json extension)'), }), - execute: async ({ workflowName }: { workflowName: string }) => { + execute: async ({ agentName }: { agentName: string }) => { try { - const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`; - const filePath = path.join(BASE_DIR, 'workflows', filename); + const filename = agentName.endsWith('.json') ? agentName : `${agentName}.json`; + const filePath = path.join(BASE_DIR, 'agents', filename); const content = await fs.readFile(filePath, 'utf-8'); - const workflow = JSON.parse(content); + const agent = JSON.parse(content); // Extract key information + const toolsList = agent.tools ? Object.keys(agent.tools) : []; + const agentTools = agent.tools ? Object.entries(agent.tools).map(([key, tool]: [string, any]) => ({ + key, + type: tool.type, + name: tool.name || key, + })) : []; + const analysis = { - name: workflow.name, - description: workflow.description || 'No description', - agentCount: workflow.agents ? workflow.agents.length : 0, - agents: workflow.agents || [], - tools: workflow.tools || {}, - structure: workflow, + name: agent.name, + description: agent.description || 'No description', + model: agent.model || 'Not specified', + toolCount: toolsList.length, + tools: agentTools, + hasOtherAgents: agentTools.some((t: any) => t.type === 'agent'), + structure: agent, }; return { @@ -291,7 +299,7 @@ export const BuiltinTools: z.infer = { } catch (error) { return { success: false, - message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: `Failed to analyze agent: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } }, From 39f0f5af790de3976efe5f09ab6e9960d794ad23 Mon Sep 17 00:00:00 2001 From: arkml Date: Mon, 17 Nov 2025 23:35:49 +0530 Subject: [PATCH 29/38] Update README.md --- README.md | 62 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a764f107..ee0fc023 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![ui](/assets/banner.png) -

CLI Tool for Background Agents

+

RowboatX - CLI Tool for Background Agents

@@ -40,50 +40,54 @@ - E.g. "Generate a NotebookLM-style podcast from my saved articles every morning" - 🔧 **Connect any MCP server to add capabilities** - Add MCP servers and RowboatX handles the integration -- 🎯 **Control agents with standard Unix commands** - - RowboatX uses bash to spawn, monitor, and manage all background agents +- 🎯 **Let RowboatX control and monitor your background agents** + - Easily inspect state on the filesystem -With full terminal access and filesystem-as-state architecture, RowboatX gives you complete control over powerful AI automations! +Inspired by Claude Code, RowboatX brings the same shell-native power to background automations. ## Quick start -1. Set your OpenAI key +1. Set your LLM API key. Supports OpenAI, Anthropic, Gemini, OpenRouter, LiteLLM, Ollama, and more. ```bash export OPENAI_API_KEY=your-openai-api-key ``` -2. Clone the repository and start Rowboat (requires Docker) +2. Install RowboatX ```bash - git clone git@github.com:rowboatlabs/rowboat.git - cd rowboat - ./start.sh + npx @rowboatlabs/rowboatx ``` -3. Access the app at [http://localhost:3000](http://localhost:3000). - -To add tools, RAG, more LLMs, and triggers checkout the [Advanced](#advanced) section below. - ## Demos #### Meeting-prep assistant Chat with the copilot to build a meeting-prep workflow, then add a calendar invite as a trigger. Watch the full demo [here](https://youtu.be/KZTP4xZM2DY). [![meeting-prep](https://github.com/user-attachments/assets/27755ef5-6549-476f-b9c0-50bef8770384)](https://youtu.be/KZTP4xZM2DY) -#### Customer support assistant -Chat with the copilot to build a customer support assistant, then connect your MCP server, and data for RAG. Watch the full demo [here](https://youtu.be/Xfo-OfgOl8w). -[![output](https://github.com/user-attachments/assets/97485fd7-64c3-4d60-a627-f756a89dee64)](https://youtu.be/Xfo-OfgOl8w) +## Examples +### Add and Manage MCP servers +`$ rowboatx` +- Add MCP: 'Add this MCP server config: \ ' +- Explore tools: 'What tools are there in \ ' -#### Personal assistant -Chat with the copilot to build a personal assistant. Watch the full demo [here](https://youtu.be/6r7P4Vlcn2g). -[![personal-assistant](https://github.com/user-attachments/assets/0f1c0ffd-23ba-4b49-8bfb-ec7a846f1332)](https://youtu.be/6r7P4Vlcn2g) +### Create background agents +`$ rowboatx` +- 'Create agent to do X.' +- '... Attach the correct tools from \ to the agent' +- '... Allow the agent to run shell commands including ffmpeg' -## Advanced -1. Native RAG Support: Enable file uploads and URL scraping with Rowboat's built-in RAG capabilities – see [RAG Guide](https://docs.rowboatlabs.com/docs/using-rowboat/rag). +### Schedule and monitor agents +`$ rowboatx` +- 'Make agent \ run every day at 10 AM' +- 'What agents do I have scheduled to run and at what times' +- 'When was \ last run' +- 'Are any agents waiting for my input or confirmation' -2. Custom LLM Providers: Use any LLM provider, including aggregators like OpenRouter and LiteLLM - see [Using more LLM providers](https://docs.rowboatlabs.com/docs/using-rowboat/customise/custom-llms). +### Run background agents manually +``` bash +rowboatx --agent= --input="xyz" --no-interactive=true +``` +```bash +rowboatx --agent= # resume from a previous run +``` + +## Rowboat Classic UI -3. Tools & Triggers: Add tools and event triggers (e.g., Gmail, Slack) for automation – see [Tools](https://docs.rowboatlabs.com/docs/using-rowboat/tools) & [Triggers](https://docs.rowboatlabs.com/docs/using-rowboat/triggers). - -4. API & SDK: Integrate Rowboat agents directly into your app – see [API](https://docs.rowboatlabs.com/docs/api-sdk/using_the_api) & [SDK](https://docs.rowboatlabs.com/docs/api-sdk/using_the_sdk) docs. - -## - -Refer to [Docs](https://docs.rowboatlabs.com/) to learn how to start building agents with Rowboat. +To use Rowboat Classic UI (not RowboatX), refer to [Classic](https://docs.rowboatlabs.com/). From 36530c2ccde83f64c2c67c12c8f0755113f41aee Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 02:28:49 +0530 Subject: [PATCH 30/38] add back ask-human support --- apps/cli/src/app.ts | 75 +++++++++++++++---- .../src/application/entities/run-events.ts | 1 + apps/cli/src/application/lib/agent.ts | 63 ++++++++++++++-- apps/cli/src/application/lib/exec-tool.ts | 5 ++ .../src/application/lib/stream-renderer.ts | 11 +++ 5 files changed, 131 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index eb00e35d..7b1ba6fb 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -8,7 +8,7 @@ import { RunEvent, RunStartEvent } from "./application/entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; import { runIdGenerator } from "./application/lib/run-id-gen.js"; import { Agent } from "./application/entities/agent.js"; -import { MessageList } from "./application/entities/message.js"; +import { Message, MessageList, ToolMessage, UserMessage } from "./application/entities/message.js"; import { z } from "zod"; import { CopilotAgent } from "./application/assistant/agent.js"; @@ -18,7 +18,7 @@ export async function app(opts: { input?: string; noInteractive?: boolean; }) { - let inputCount = 0; + let askHumanEventMarker: z.infer & { type: "pause-for-human-input" } | null = null; const messages: z.infer = []; const renderer = new StreamRenderer(); @@ -41,7 +41,17 @@ export async function app(opts: { switch (event.type) { case "message": messages.push(event.message); + if (askHumanEventMarker + && event.message.role === "tool" + && event.message.toolCallId === askHumanEventMarker.toolCallId + ) { + askHumanEventMarker = null; + } break; + case "pause-for-human-input": { + askHumanEventMarker = event; + break; + } } } } finally { @@ -49,15 +59,6 @@ export async function app(opts: { } } - // add user input - if (opts.input) { - messages.push({ - role: "user", - content: opts.input, - }); - inputCount++; - } - // create runId if not present if (!runId) { runId = runIdGenerator.next(); @@ -87,6 +88,10 @@ export async function app(opts: { } // loop between user and agent + // add user input from cli, if present + if (opts.input) { + handleUserInput(opts.input, messages, askHumanEventMarker, renderer, logger); + } let rl: Interface | null = null; if (!opts.noInteractive) { rl = createInterface({ input, output }); @@ -109,11 +114,7 @@ export async function app(opts: { console.error("Bye!"); return; } - inputCount++; - messages.push({ - role: "user", - content: userInput, - }); + handleUserInput(userInput, messages, askHumanEventMarker, renderer, logger); } for await (const event of streamAgentTurn({ agent, @@ -121,6 +122,9 @@ export async function app(opts: { })) { logger.log(event); renderer.render(event); + if (event.type === "pause-for-human-input") { + askHumanEventMarker = event; + } if (event?.type === "error") { process.exitCode = 1; } @@ -134,4 +138,43 @@ export async function app(opts: { logger.close(); rl?.close(); } +} + +function handleUserInput( + input: string, + messages: z.infer, + askHumanEventMarker: z.infer & { type: "pause-for-human-input" } | null, + renderer: StreamRenderer, + logger: RunLogger, +) { + // if waiting on human input, send as response + if (askHumanEventMarker) { + const message = { + role: "tool", + content: JSON.stringify({ + userResponse: input, + }), + toolCallId: askHumanEventMarker.toolCallId, + toolName: "ask-human", + } as z.infer; + messages.push(message); + const ev = { + type: "message", + message, + } as z.infer; + logger.log(ev); + renderer.render(ev); + askHumanEventMarker = null; + } else { + const message = { + role: "user", + content: input, + } as z.infer; + messages.push(message); + const ev = { + type: "message", + message, + } as z.infer; + logger.log(ev); + } } \ No newline at end of file diff --git a/apps/cli/src/application/entities/run-events.ts b/apps/cli/src/application/entities/run-events.ts index 1ce6a7a6..6784c845 100644 --- a/apps/cli/src/application/entities/run-events.ts +++ b/apps/cli/src/application/entities/run-events.ts @@ -50,6 +50,7 @@ export const RunEndEvent = BaseRunEvent.extend({ export const RunPauseEvent = BaseRunEvent.extend({ type: z.literal("pause-for-human-input"), toolCallId: z.string(), + question: z.string(), }); export const RunResumeEvent = BaseRunEvent.extend({ diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 3661acaf..3ee99728 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -12,7 +12,6 @@ import { getProvider } from "./models.js"; import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; import { execTool } from "./exec-tool.js"; import { RunEvent } from "../entities/run-events.js"; -import { CopilotAgent } from "../assistant/agent.js"; import { BuiltinTools } from "./builtin-tools.js"; export async function mapAgentTool(t: z.infer): Promise { @@ -36,6 +35,14 @@ export async function mapAgentTool(t: z.infer): Promise) { + if (typeof message.content === "string") { + return; + } + let askHumanToolCall: z.infer | null = null; + const newParts = []; + for (const part of message.content as z.infer[]) { + if (part.type === "tool-call" && part.toolName === "ask-human") { + if (!askHumanToolCall) { + askHumanToolCall = part; + } else { + (askHumanToolCall as z.infer).arguments += "\n" + part.arguments; + } + break; + } else { + newParts.push(part); + } + } + if (askHumanToolCall) { + newParts.push(askHumanToolCall); + } + message.content = newParts; +} + export async function loadAgent(id: string): Promise> { const agentPath = path.join(WorkDir, "agents", `${id}.json`); const agent = fs.readFileSync(agentPath, "utf8"); @@ -240,6 +271,7 @@ export async function* streamAgentTurn(opts: { // build and emit final message from agent response const msg = messageBuilder.get(); + normaliseAskHumanToolCall(msg); messages.push(msg); yield { type: "message", @@ -266,7 +298,11 @@ export async function* streamAgentTurn(opts: { }); } + // first, handle tool calls other than ask-human for (const call of mappedToolCalls) { + if (call.toolCall.toolName === "ask-human") { + continue; + } const { agentTool, toolCall } = call; yield { type: "tool-invocation", @@ -292,13 +328,24 @@ export async function* streamAgentTurn(opts: { }; } + // then, handle ask-human (only first one) + const askHumanCall = mappedToolCalls.filter(call => call.toolCall.toolName === "ask-human")[0]; + if (askHumanCall) { + yield { + type: "pause-for-human-input", + toolCallId: askHumanCall.toolCall.toolCallId, + question: askHumanCall.toolCall.arguments.question as string, + }; + return; + } + // if the agent response had tool calls, replay this agent if (hasToolCalls) { continue; } // otherwise, break - break; + return; } } @@ -314,12 +361,12 @@ async function* streamLlm( system: instructions, tools, stopWhen: stepCountIs(1), - providerOptions: { - openai: { - reasoningEffort: "low", - reasoningSummary: "auto", - }, - } + // providerOptions: { + // openai: { + // reasoningEffort: "low", + // reasoningSummary: "auto", + // }, + // } }); for await (const event of fullStream) { // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index 3c78e619..5ed146ce 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -67,6 +67,11 @@ async function execAgentTool(agentTool: z.infer & { type: if (event.type === "message" && event.message.role === "assistant") { lastMsg = event.message; } + if (event.type === "pause-for-human-input") { + return `I need more information from a human in order to continue. I should use the ask-human tool to ask the user for a response on the question below. Once the user comes back with an answer, call this tool again with the answer embedded in the original input that you used to call this tool the first time. + + Question: ${event.question}`; + } if (event.type === "error") { throw new Error(event.error); } diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index 341be492..136b0357 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -62,6 +62,10 @@ export class StreamRenderer { this.onError(event.error); break; } + case "pause-for-human-input": { + this.onPauseForHumanInput(event.toolCallId, event.question); + break; + } } } @@ -194,6 +198,13 @@ export class StreamRenderer { this.write("\n"); } + private onPauseForHumanInput(toolCallId: string, question: string) { + this.write(this.cyan(`\n→ Pause for human input (${toolCallId})`)); + this.write("\n"); + this.write(this.bold("Question: ") + question); + this.write("\n"); + } + private onUsage(usage: { inputTokens?: number; outputTokens?: number; From fb542afc383f1c34718adf5736eebbe140d801cc Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 18 Nov 2025 15:50:49 +0530 Subject: [PATCH 31/38] Refactor StreamRenderer for improved output formatting and clarity. Enhanced visual structure for agent events, tool invocations, and usage statistics. Removed deprecated provider options from agent stream function. --- apps/cli/src/application/lib/agent.ts | 6 -- .../src/application/lib/stream-renderer.ts | 99 ++++++++++++++----- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 3661acaf..7449328a 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -314,12 +314,6 @@ async function* streamLlm( system: instructions, tools, stopWhen: stepCountIs(1), - providerOptions: { - openai: { - reasoningEffort: "low", - reasoningSummary: "auto", - }, - } }); for await (const event of fullStream) { // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index 341be492..74c68dfa 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -13,6 +13,7 @@ export class StreamRenderer { private options: Required; private reasoningActive = false; private textActive = false; + private firstText = true; constructor(options?: StreamRendererOptions) { this.options = { @@ -96,26 +97,38 @@ export class StreamRenderer { private onStart(agent: string, runId: string) { this.write("\n"); - this.write(this.bold(`▶ Agent ${agent} (run ${runId})`)); + this.write(this.bold(this.cyan(`╭─ Agent: ${agent}`))); + this.write(this.dim(` │ run ${runId}`)); this.write("\n"); + this.write(this.dim(`╰─────────────────────────────────────────────────\n`)); } private onEnd() { - this.write(this.bold("\n■ complete\n")); + this.write("\n"); + this.write(this.dim("─".repeat(50))); + this.write("\n"); + this.write(this.green(this.bold("✓ Complete"))); + this.write("\n\n"); } private onError(error: string) { - this.write(this.red(`\n✖ error: ${error}\n`)); + this.write("\n"); + this.write(this.red(this.bold("✖ Error"))); + this.write("\n"); + this.write(this.red(this.indent(error))); + this.write("\n\n"); } private onStepStart() { this.write("\n"); - this.write(this.cyan(`─ Step started`)); + this.write(this.dim("│ ")); + this.write(this.dim("Step in progress...")); this.write("\n"); } private onStepEnd() { - this.write(this.dim(`✓ Step finished\n`)); + // More subtle step end - just add a little spacing + this.write(this.dim("\n")); } private onStepMessage(stepIndex: number, message: any) { @@ -131,18 +144,22 @@ export class StreamRenderer { } private onStepToolInvocation(toolName: string, input: string) { - this.write(this.cyan(`\n→ Tool invoke ${toolName}`)); + this.write("\n"); + this.write(this.cyan("┌─ ") + this.bold(this.cyan(`🔧 ${toolName}`))); + this.write("\n"); if (input && input.length) { - this.write("\n" + this.dim(this.indent(this.truncate(input))) + "\n"); - } else { + this.write(this.dim("│ ") + this.dim(this.indent(this.truncate(input)).replace(/\n/g, "\n│ "))); this.write("\n"); } } private onStepToolResult(toolName: string, result: unknown) { const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent)); - this.write(this.cyan(`\n← Tool result ${toolName}\n`)); - this.write(this.dim(this.indent(res)) + "\n"); + this.write(this.dim("│\n")); + this.write(this.green("└─ ") + this.dim(this.green(`Result`))); + this.write("\n"); + this.write(this.dim(" " + this.indent(res).replace(/\n/g, "\n "))); + this.write("\n"); } private onReasoningStart() { @@ -150,7 +167,8 @@ export class StreamRenderer { this.reasoningActive = true; if (this.options.showHeaders) { this.write("\n"); - this.write(this.dim("Reasoning: ")); + this.write(this.dim("│ ")); + this.write(this.dim(this.italic("thinking... "))); } } @@ -162,21 +180,32 @@ export class StreamRenderer { private onReasoningEnd() { if (!this.reasoningActive) return; this.reasoningActive = false; - this.write(this.dim("\n")); + this.write("\n"); } private onTextStart() { if (this.textActive) return; this.textActive = true; - if (this.options.showHeaders) { + if (this.options.showHeaders && this.firstText) { this.write("\n"); - this.write(this.bold("Assistant: ")); + this.write(this.bold("╭─ ") + this.bold("Response")); + this.write("\n"); + this.write(this.dim("│\n")); + this.firstText = false; + } else if (this.options.showHeaders) { + this.write("\n"); + this.write(this.dim("│ ")); } } private onTextDelta(delta: string) { if (!this.textActive) this.onTextStart(); - this.write(delta); + // Add subtle left margin to assistant text for better readability + if (delta.includes("\n")) { + this.write(delta.replace(/\n/g, "\n ")); + } else { + this.write(delta); + } } private onTextEnd() { @@ -188,10 +217,12 @@ export class StreamRenderer { private onToolCall(toolCallId: string, toolName: string, input: unknown) { const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent)); this.write("\n"); - this.write(this.cyan(`→ Tool call ${toolName} (${toolCallId})`)); + this.write(this.magenta("┌─ ") + this.bold(this.magenta(`⚡ ${toolName}`))); + this.write(this.dim(` (${toolCallId.slice(0, 8)}...)`)); this.write("\n"); - this.write(this.dim(this.indent(inputStr))); + this.write(this.dim("│ ") + this.dim(this.indent(inputStr).replace(/\n/g, "\n│ "))); this.write("\n"); + this.write(this.dim("└─────────────\n")); } private onUsage(usage: { @@ -202,13 +233,17 @@ export class StreamRenderer { cachedInputTokens?: number; }) { const parts: string[] = []; - if (usage.inputTokens !== undefined) parts.push(`input=${usage.inputTokens}`); - if (usage.outputTokens !== undefined) parts.push(`output=${usage.outputTokens}`); - if (usage.reasoningTokens !== undefined) parts.push(`reasoning=${usage.reasoningTokens}`); - if (usage.cachedInputTokens !== undefined) parts.push(`cached=${usage.cachedInputTokens}`); - if (usage.totalTokens !== undefined) parts.push(`total=${usage.totalTokens}`); - const line = parts.join(", "); - this.write(this.dim(`\nUsage: ${line}\n`)); + if (usage.inputTokens !== undefined) parts.push(`${this.dim("in:")} ${usage.inputTokens}`); + if (usage.outputTokens !== undefined) parts.push(`${this.dim("out:")} ${usage.outputTokens}`); + if (usage.reasoningTokens !== undefined) parts.push(`${this.dim("reasoning:")} ${usage.reasoningTokens}`); + if (usage.cachedInputTokens !== undefined) parts.push(`${this.dim("cached:")} ${usage.cachedInputTokens}`); + if (usage.totalTokens !== undefined) parts.push(`${this.dim("total:")} ${this.bold(usage.totalTokens.toString())}`); + const line = parts.join(this.dim(" | ")); + this.write("\n"); + this.write(this.dim("╭─ Usage\n")); + this.write(this.dim("│ ") + line); + this.write("\n"); + this.write(this.dim("╰─────────────\n")); } // Formatting helpers @@ -236,13 +271,29 @@ export class StreamRenderer { return "\x1b[2m" + text + "\x1b[0m"; } + private italic(text: string): string { + return "\x1b[3m" + text + "\x1b[0m"; + } + private cyan(text: string): string { return "\x1b[36m" + text + "\x1b[0m"; } + private green(text: string): string { + return "\x1b[32m" + text + "\x1b[0m"; + } + private red(text: string): string { return "\x1b[31m" + text + "\x1b[0m"; } + + private magenta(text: string): string { + return "\x1b[35m" + text + "\x1b[0m"; + } + + private yellow(text: string): string { + return "\x1b[33m" + text + "\x1b[0m"; + } } From 570543e1c78372dbe65330e6ff85bc0f1f6398b7 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 18 Nov 2025 16:17:52 +0530 Subject: [PATCH 32/38] Enhance assistant instructions for clarity and conciseness. Update models for builtin tools and workflow authoring skills to gpt-5.1. Improve text formatting in StreamRenderer for better readability. --- apps/cli/src/application/assistant/instructions.ts | 2 ++ .../assistant/skills/builtin-tools/skill.ts | 8 ++++---- .../assistant/skills/workflow-authoring/skill.ts | 8 ++++---- apps/cli/src/application/config/config.ts | 2 +- apps/cli/src/application/lib/stream-renderer.ts | 10 +++++++--- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index b5996d8b..a99e9d4b 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -14,6 +14,8 @@ Always consult this catalog first so you load the right skills before taking act # Communication & Execution Style ## Communication principles +- Be concise and direct. Avoid verbose explanations unless the user asks for details. +- Only show JSON output when explicitly requested by the user. Otherwise, summarize results in plain language. - Break complex efforts into clear, sequential steps the user can follow. - Explain reasoning briefly as you work, and confirm outcomes before moving on. - Be proactive about understanding missing context; ask clarifying questions when needed. diff --git a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts index 1d7f26f3..217d7f91 100644 --- a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts @@ -54,7 +54,7 @@ Agents can use builtin tools by declaring them in the \`"tools"\` object with \` { "name": "arxiv-feed-reader", "description": "A feed reader for the arXiv", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Extract latest papers from the arXiv feed and summarize them. Use curl to fetch the RSS feed, then parse it with yq and jq:\n\ncurl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test(\"agent\"; \"i\")) | \"\\(.title)\\n\\(.link)\\n\\(.description)\\n\"'\n\nThis will give you papers containing 'agent' in the title.", "tools": { "bash": { @@ -70,7 +70,7 @@ Agents can use builtin tools by declaring them in the \`"tools"\` object with \` { "name": "system-monitor", "description": "Monitor system resources and processes", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Monitor system resources using bash commands. Use 'df -h' for disk usage, 'free -h' for memory, 'top -bn1' for processes, 'ps aux' for process list. Parse the output and report any issues.", "tools": { "bash": { @@ -86,7 +86,7 @@ Agents can use builtin tools by declaring them in the \`"tools"\` object with \` { "name": "git-helper", "description": "Automate git operations", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Help with git operations. Use commands like 'git status', 'git log --oneline -10', 'git diff', 'git branch -a' to inspect the repository. Can also run 'git add', 'git commit', 'git push' when instructed.", "tools": { "bash": { @@ -127,7 +127,7 @@ Agents can call other agents as tools to create complex multi-step workflows. Th \`\`\`json { "name": "paper_analyzer", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the summariser. Don't ask for human input.", "tools": { "summariser": { diff --git a/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts index a710d1f5..dd6dfc0e 100644 --- a/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts +++ b/apps/cli/src/application/assistant/skills/workflow-authoring/skill.ts @@ -24,7 +24,7 @@ Load this skill whenever a user wants to inspect, create, or update agents insid { "name": "agent_name", "description": "Description of the agent", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Instructions for the agent", "tools": { "descriptive_tool_key": { @@ -91,7 +91,7 @@ Load this skill whenever a user wants to inspect, create, or update agents insid { "name": "summariser_agent", "description": "Summarises an arxiv paper", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Download and summarise an arxiv paper. Use curl to fetch the PDF. Output just the GIST in two lines. Don't ask for human input.", "tools": { "bash": {"type": "builtin", "name": "executeCommand"} @@ -104,7 +104,7 @@ Load this skill whenever a user wants to inspect, create, or update agents insid { "name": "summarise-a-few", "description": "Summarises multiple arxiv papers", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "Pick 2 interesting papers and summarise each using the summariser tool. Pass the paper URL to the tool. Don't ask for human input.", "tools": { "summariser": { @@ -120,7 +120,7 @@ Load this skill whenever a user wants to inspect, create, or update agents insid { "name": "podcast_workflow", "description": "Create a podcast from arXiv papers", - "model": "gpt-4.1", + "model": "gpt-5.1", "instructions": "1. Fetch arXiv papers about agents using bash\n2. Pick papers and summarise them using summarise_papers\n3. Create a podcast transcript\n4. Generate audio using text_to_speech\n\nExecute these steps in sequence.", "tools": { "bash": {"type": "builtin", "name": "executeCommand"}, diff --git a/apps/cli/src/application/config/config.ts b/apps/cli/src/application/config/config.ts index 24c0d013..0f2da020 100644 --- a/apps/cli/src/application/config/config.ts +++ b/apps/cli/src/application/config/config.ts @@ -34,7 +34,7 @@ const baseModelConfig: z.infer = { }, defaults: { provider: "openai", - model: "gpt-5", + model: "gpt-5.1", } }; diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index b4ba278e..ebcc92b7 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -203,12 +203,12 @@ export class StreamRenderer { } private onTextDelta(delta: string) { - if (!this.textActive) this.onTextStart(); // Add subtle left margin to assistant text for better readability + const formattedDelta = this.neutral(delta); if (delta.includes("\n")) { - this.write(delta.replace(/\n/g, "\n ")); + this.write(formattedDelta.replace(/\n/g, "\n ")); } else { - this.write(delta); + this.write(formattedDelta); } } @@ -305,6 +305,10 @@ export class StreamRenderer { private yellow(text: string): string { return "\x1b[33m" + text + "\x1b[0m"; } + + private neutral(text: string): string { + return "\x1b[38;5;250m" + text + "\x1b[0m"; + } } From 28488d5fd11271e12ed8ed6f7852b7d6a52d4e8b Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 18 Nov 2025 20:42:11 +0530 Subject: [PATCH 33/38] Add security allowlist for command execution and update copilot instructions - Add security.ts with allowlist configuration for shell commands - Update command-executor.ts to enforce security policy (exit code 126 for blocked commands) - Update copilot instructions to clarify builtin tools vs shell commands - Document that builtin tools (deleteFile, createFile, etc.) bypass security filtering - Only executeCommand (shell commands) requires security.json allowlist entries --- .../src/application/assistant/instructions.ts | 15 ++++ .../assistant/skills/builtin-tools/skill.ts | 3 +- apps/cli/src/application/config/security.ts | 90 +++++++++++++++++++ .../src/application/lib/command-executor.ts | 76 ++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/application/config/security.ts diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index a99e9d4b..3fd32af6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -26,4 +26,19 @@ Always consult this catalog first so you load the right skills before taking act - Explore existing files and structure before creating new assets. - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. + +## Builtin Tools vs Shell Commands + +**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries: +- \`deleteFile\`, \`createFile\`, \`updateFile\`, \`readFile\` - File operations +- \`listFiles\`, \`exploreDirectory\` - Directory exploration +- \`analyzeAgent\` - Agent analysis +- \`listMcpServers\`, \`listMcpTools\` - MCP server management +- \`loadSkill\` - Skill loading + +These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`. + +**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`deleteFile\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`createFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. + +The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools. `; diff --git a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts index 217d7f91..2467367b 100644 --- a/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts +++ b/apps/cli/src/application/assistant/skills/builtin-tools/skill.ts @@ -10,6 +10,8 @@ Agents can use builtin tools by declaring them in the \`"tools"\` object with \` ### executeCommand **The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output. +**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy. + **Agent tool declaration:** \`\`\`json "tools": { @@ -176,4 +178,3 @@ There are no separate "workflow" files - everything is an agent! `; export default skill; - diff --git a/apps/cli/src/application/config/security.ts b/apps/cli/src/application/config/security.ts new file mode 100644 index 00000000..35809f14 --- /dev/null +++ b/apps/cli/src/application/config/security.ts @@ -0,0 +1,90 @@ +import path from "path"; +import fs from "fs"; +import { WorkDir } from "./config.js"; + +export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"); + +const DEFAULT_ALLOW_LIST = ["ls", "pwd", "cat", "echo", "whoami"]; + +let cachedAllowList: string[] | null = null; +let cachedMtimeMs: number | null = null; + +function ensureSecurityConfig() { + if (!fs.existsSync(SECURITY_CONFIG_PATH)) { + fs.writeFileSync( + SECURITY_CONFIG_PATH, + JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n", + "utf8", + ); + } +} + +function normalizeList(commands: unknown[]): string[] { + const seen = new Set(); + for (const entry of commands) { + if (typeof entry !== "string") continue; + const normalized = entry.trim().toLowerCase(); + if (!normalized) continue; + seen.add(normalized); + } + + return Array.from(seen); +} + +function parseSecurityPayload(payload: unknown): string[] { + if (Array.isArray(payload)) { + return normalizeList(payload); + } + + if (payload && typeof payload === "object") { + const maybeObject = payload as Record; + if (Array.isArray(maybeObject.allowedCommands)) { + return normalizeList(maybeObject.allowedCommands); + } + + const dynamicList = Object.entries(maybeObject) + .filter(([, value]) => Boolean(value)) + .map(([key]) => key); + + return normalizeList(dynamicList); + } + + return []; +} + +function readAllowList(): string[] { + ensureSecurityConfig(); + + try { + const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8"); + const parsed = JSON.parse(configContent); + return parseSecurityPayload(parsed); + } catch (error) { + console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`); + return DEFAULT_ALLOW_LIST; + } +} + +export function getSecurityAllowList(): string[] { + ensureSecurityConfig(); + try { + const stats = fs.statSync(SECURITY_CONFIG_PATH); + if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) { + return cachedAllowList; + } + + const allowList = readAllowList(); + cachedAllowList = allowList; + cachedMtimeMs = stats.mtimeMs; + return allowList; + } catch { + cachedAllowList = null; + cachedMtimeMs = null; + return readAllowList(); + } +} + +export function resetSecurityAllowListCache() { + cachedAllowList = null; + cachedMtimeMs = null; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index c8ba6939..369dca0a 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -1,7 +1,73 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; +import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../config/security.js'; const execPromise = promisify(exec); +const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; +const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; +const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); + +function sanitizeToken(token: string): string { + return token.trim().replace(/^['"]+|['"]+$/g, ''); +} + +function extractCommandNames(command: string): string[] { + const discovered = new Set(); + const segments = command.split(COMMAND_SPLIT_REGEX); + + for (const segment of segments) { + const tokens = segment.trim().split(/\s+/).filter(Boolean); + if (!tokens.length) continue; + + let index = 0; + while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index])) { + index++; + } + + if (index >= tokens.length) continue; + + const primary = sanitizeToken(tokens[index]).toLowerCase(); + if (!primary) continue; + + discovered.add(primary); + + if (WRAPPER_COMMANDS.has(primary) && index + 1 < tokens.length) { + const wrapped = sanitizeToken(tokens[index + 1]).toLowerCase(); + if (wrapped) { + discovered.add(wrapped); + } + } + } + + return Array.from(discovered); +} + +function findBlockedCommands(command: string): string[] { + const invoked = extractCommandNames(command); + if (!invoked.length) return []; + + const allowList = getSecurityAllowList(); + if (!allowList.length) return invoked; + + const allowSet = new Set(allowList); + if (allowSet.has('*')) return []; + + return invoked.filter((cmd) => !allowSet.has(cmd)); +} + +function enforceSecurity(command: string): CommandResult | null { + const blocked = findBlockedCommands(command); + + if (!blocked.length) { + return null; + } + + return { + stdout: '', + stderr: `Command blocked by security policy. Blocked command(s): ${blocked.join(', ')}. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`, + exitCode: 126, + }; +} export interface CommandResult { stdout: string; @@ -23,6 +89,11 @@ export async function executeCommand( maxBuffer?: number; // max buffer size in bytes } ): Promise { + const securityResult = enforceSecurity(command); + if (securityResult) { + return securityResult; + } + try { const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, @@ -57,6 +128,11 @@ export function executeCommandSync( timeout?: number; } ): CommandResult { + const securityResult = enforceSecurity(command); + if (securityResult) { + return securityResult; + } + try { const stdout = execSync(command, { cwd: options?.cwd, From 7d4484e7c02dae2a086934c08bba8ecdf66473d3 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:27:11 +0530 Subject: [PATCH 34/38] structured ask human and permissions refactor --- apps/cli/src/app.ts | 198 ++++----- .../src/application/entities/run-events.ts | 76 ++-- apps/cli/src/application/lib/agent.ts | 413 ++++++++++++++---- apps/cli/src/application/lib/exec-tool.ts | 42 -- .../src/application/lib/stream-renderer.ts | 25 +- 5 files changed, 447 insertions(+), 307 deletions(-) diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 7b1ba6fb..5d2be860 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -1,16 +1,13 @@ -import { loadAgent, RunLogger, streamAgentTurn } from "./application/lib/agent.js"; +import { AgentState, streamAgent } from "./application/lib/agent.js"; import { StreamRenderer } from "./application/lib/stream-renderer.js"; import { stdin as input, stdout as output } from "node:process"; import fs from "fs"; import path from "path"; import { WorkDir } from "./application/config/config.js"; -import { RunEvent, RunStartEvent } from "./application/entities/run-events.js"; +import { RunEvent } from "./application/entities/run-events.js"; import { createInterface, Interface } from "node:readline/promises"; -import { runIdGenerator } from "./application/lib/run-id-gen.js"; -import { Agent } from "./application/entities/agent.js"; -import { Message, MessageList, ToolMessage, UserMessage } from "./application/entities/message.js"; +import { ToolCallPart } from "./application/entities/message.js"; import { z } from "zod"; -import { CopilotAgent } from "./application/assistant/agent.js"; export async function app(opts: { agent: string; @@ -18,9 +15,8 @@ export async function app(opts: { input?: string; noInteractive?: boolean; }) { - let askHumanEventMarker: z.infer & { type: "pause-for-human-input" } | null = null; - const messages: z.infer = []; const renderer = new StreamRenderer(); + const state = new AgentState(opts.agent); // load existing and assemble state if required let runId = opts.runId; @@ -38,143 +34,107 @@ export async function app(opts: { } const parsed = JSON.parse(line); const event = RunEvent.parse(parsed); - switch (event.type) { - case "message": - messages.push(event.message); - if (askHumanEventMarker - && event.message.role === "tool" - && event.message.toolCallId === askHumanEventMarker.toolCallId - ) { - askHumanEventMarker = null; - } - break; - case "pause-for-human-input": { - askHumanEventMarker = event; - break; - } - } + state.ingest(event); } } finally { stream?.close(); } } - // create runId if not present - if (!runId) { - runId = runIdGenerator.next(); - } - const logger = new RunLogger(runId); - - // load agent data - let agent: z.infer | null = null; - if (opts.agent === "copilot") { - agent = CopilotAgent; - } else { - agent = await loadAgent(opts.agent); - } - if (!agent) { - throw new Error("unable to load agent"); - } - - // emit start event if first time run - if (!opts.runId) { - const ev = { - type: "start", - runId, - agent: agent.name, - } as z.infer; - logger.log(ev); - renderer.render(ev); - } - - // loop between user and agent - // add user input from cli, if present - if (opts.input) { - handleUserInput(opts.input, messages, askHumanEventMarker, renderer, logger); - } let rl: Interface | null = null; if (!opts.noInteractive) { rl = createInterface({ input, output }); } - let firstPass = true; + try { while (true) { - let askInput = false; - if (firstPass) { - if (!opts.input) { - askInput = true; - } - firstPass = false; - } else { - askInput = true; + // ask for pending tool permissions + for (const perm of Object.values(state.getPendingPermissions())) { + const response = await getToolCallPermission(perm.toolCall, rl!); + state.ingestAndLog({ + type: "tool-permission-response", + response, + toolCallId: perm.toolCall.toolCallId, + subflow: perm.subflow, + }); } - if (rl && askInput) { - const userInput = await rl.question("You: "); - if (["quit", "exit", "q"].includes(userInput.trim().toLowerCase())) { - console.error("Bye!"); - return; - } - handleUserInput(userInput, messages, askHumanEventMarker, renderer, logger); + + // ask for pending human input + for (const ask of Object.values(state.getPendingAskHumans())) { + const response = await getAskHumanResponse(ask.query, rl!); + state.ingestAndLog({ + type: "ask-human-response", + response, + toolCallId: ask.toolCallId, + subflow: ask.subflow, + }); } - for await (const event of streamAgentTurn({ - agent, - messages, - })) { - logger.log(event); + + // run one turn + for await (const event of streamAgent(state)) { renderer.render(event); - if (event.type === "pause-for-human-input") { - askHumanEventMarker = event; - } if (event?.type === "error") { process.exitCode = 1; } } - if (opts.noInteractive) { - break; + // if nothing pending, get user input + if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) { + const response = await getUserInput(rl!); + state.ingestAndLog({ + type: "message", + message: { + role: "user", + content: response, + }, + subflow: [], + }); } } } finally { - logger.close(); rl?.close(); } } -function handleUserInput( - input: string, - messages: z.infer, - askHumanEventMarker: z.infer & { type: "pause-for-human-input" } | null, - renderer: StreamRenderer, - logger: RunLogger, -) { - // if waiting on human input, send as response - if (askHumanEventMarker) { - const message = { - role: "tool", - content: JSON.stringify({ - userResponse: input, - }), - toolCallId: askHumanEventMarker.toolCallId, - toolName: "ask-human", - } as z.infer; - messages.push(message); - const ev = { - type: "message", - message, - } as z.infer; - logger.log(ev); - renderer.render(ev); - askHumanEventMarker = null; - } else { - const message = { - role: "user", - content: input, - } as z.infer; - messages.push(message); - const ev = { - type: "message", - message, - } as z.infer; - logger.log(ev); +async function getToolCallPermission( + call: z.infer, + rl: Interface, +): Promise<"approve" | "deny"> { + const question = `Do you want to allow running the following tool: ${call.toolName}?: + + Tool name: ${call.toolName} + Tool arguments: ${JSON.stringify(call.arguments)} + + Choices: y/n/a/d: + - y: approve + - n: deny + `; + const input = await rl.question(question); + if (input.toLowerCase() === "y") return "approve"; + if (input.toLowerCase() === "n") return "deny"; + return "deny"; +} + +async function getAskHumanResponse( + query: string, + rl: Interface, +): Promise { + const input = await rl.question(`The agent is asking for your help with the following query: + + Question: ${query} + + Please respond to the question. + `); + return input; +} + +async function getUserInput( + rl: Interface, +): Promise { + const input = await rl.question("You: "); + if (["quit", "exit", "q"].includes(input.toLowerCase().trim())) { + console.error("Bye!"); + process.exit(0); } + return input; } \ No newline at end of file diff --git a/apps/cli/src/application/entities/run-events.ts b/apps/cli/src/application/entities/run-events.ts index 6784c845..bdd0c13a 100644 --- a/apps/cli/src/application/entities/run-events.ts +++ b/apps/cli/src/application/entities/run-events.ts @@ -1,60 +1,68 @@ -import { z } from "zod"; import { LlmStepStreamEvent } from "./llm-step-events.js"; -import { Message } from "./message.js"; +import { Message, ToolCallPart } from "./message.js"; import { Agent } from "./agent.js"; +import z from "zod"; const BaseRunEvent = z.object({ ts: z.iso.datetime().optional(), + subflow: z.array(z.string()), }); -export const RunStartEvent = BaseRunEvent.extend({ +export const StartEvent = BaseRunEvent.extend({ type: z.literal("start"), runId: z.string(), - agent: z.string(), + agentName: z.string(), }); -export const RunStepStartEvent = BaseRunEvent.extend({ - type: z.literal("step-start"), +export const SpawnSubFlowEvent = BaseRunEvent.extend({ + type: z.literal("spawn-subflow"), + agentName: z.string(), + toolCallId: z.string(), }); -export const RunStreamEvent = BaseRunEvent.extend({ - type: z.literal("stream-event"), +export const LlmStreamEvent = BaseRunEvent.extend({ + type: z.literal("llm-stream-event"), event: LlmStepStreamEvent, }); -export const RunMessageEvent = BaseRunEvent.extend({ +export const MessageEvent = BaseRunEvent.extend({ type: z.literal("message"), message: Message, }); -export const RunToolInvocationEvent = BaseRunEvent.extend({ +export const ToolInvocationEvent = BaseRunEvent.extend({ type: z.literal("tool-invocation"), toolName: z.string(), input: z.string(), }); -export const RunToolResultEvent = BaseRunEvent.extend({ +export const ToolResultEvent = BaseRunEvent.extend({ type: z.literal("tool-result"), toolName: z.string(), result: z.any(), }); -export const RunStepEndEvent = BaseRunEvent.extend({ - type: z.literal("step-end"), -}); - -export const RunEndEvent = BaseRunEvent.extend({ - type: z.literal("end"), -}); - -export const RunPauseEvent = BaseRunEvent.extend({ - type: z.literal("pause-for-human-input"), +export const AskHumanRequestEvent = BaseRunEvent.extend({ + type: z.literal("ask-human-request"), toolCallId: z.string(), - question: z.string(), + query: z.string(), }); -export const RunResumeEvent = BaseRunEvent.extend({ - type: z.literal("resume"), +export const AskHumanResponseEvent = BaseRunEvent.extend({ + type: z.literal("ask-human-response"), + toolCallId: z.string(), + response: z.string(), +}); + +export const ToolPermissionRequestEvent = BaseRunEvent.extend({ + type: z.literal("tool-permission-request"), + toolCall: ToolCallPart, +}); + +export const ToolPermissionResponseEvent = BaseRunEvent.extend({ + type: z.literal("tool-permission-response"), + toolCallId: z.string(), + response: z.enum(["approve", "deny"]), }); export const RunErrorEvent = BaseRunEvent.extend({ @@ -63,15 +71,15 @@ export const RunErrorEvent = BaseRunEvent.extend({ }); export const RunEvent = z.union([ - RunStartEvent, - RunStepStartEvent, - RunStreamEvent, - RunMessageEvent, - RunToolInvocationEvent, - RunToolResultEvent, - RunStepEndEvent, - RunEndEvent, - RunPauseEvent, - RunResumeEvent, + StartEvent, + SpawnSubFlowEvent, + LlmStreamEvent, + MessageEvent, + ToolInvocationEvent, + ToolResultEvent, + AskHumanRequestEvent, + AskHumanResponseEvent, + ToolPermissionRequestEvent, + ToolPermissionResponseEvent, RunErrorEvent, ]); \ No newline at end of file diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 0aa6b4a9..823ad3b3 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -3,7 +3,6 @@ import fs from "fs"; import path from "path"; import { ModelConfig, WorkDir } from "../config/config.js"; import { Agent, ToolAttachment } from "../entities/agent.js"; -import { createInterface, Interface } from "node:readline/promises"; import { AssistantContentPart, AssistantMessage, Message, MessageList, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js"; import { runIdGenerator } from "./run-id-gen.js"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; @@ -11,8 +10,9 @@ import { z } from "zod"; import { getProvider } from "./models.js"; import { LlmStepStreamEvent } from "../entities/llm-step-events.js"; import { execTool } from "./exec-tool.js"; -import { RunEvent } from "../entities/run-events.js"; +import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js"; import { BuiltinTools } from "./builtin-tools.js"; +import { CopilotAgent } from "../assistant/agent.js"; export async function mapAgentTool(t: z.infer): Promise { switch (t.type) { @@ -75,7 +75,7 @@ export class RunLogger { } log(event: z.infer) { - if (event.type !== "stream-event") { + if (event.type !== "llm-stream-event") { this.fileHandle.write(JSON.stringify(event) + "\n"); } } @@ -161,6 +161,9 @@ function normaliseAskHumanToolCall(message: z.infer) { } export async function loadAgent(id: string): Promise> { + if (id === "copilot") { + return CopilotAgent; + } const agentPath = path.join(WorkDir, "agents", `${id}.json`); const agent = fs.readFileSync(agentPath, "utf8"); return Agent.parse(JSON.parse(agent)); @@ -230,14 +233,7 @@ export function convertFromMessages(messages: z.infer[]): ModelM return result; } - -export async function* streamAgentTurn(opts: { - agent: z.infer; - messages: z.infer; -}): AsyncGenerator, void, unknown> { - const { agent, messages } = opts; - - // set up tools +async function buildTools(agent: z.infer): Promise { const tools: ToolSet = {}; for (const [name, tool] of Object.entries(agent.tools ?? {})) { try { @@ -247,105 +243,340 @@ export async function* streamAgentTurn(opts: { continue; } } + return tools; +} - // set up +export class AgentState { + logger: RunLogger | null = null; + runId: string | null = null; + agent: z.infer | null = null; + agentName: string; + messages: z.infer = []; + lastAssistantMsg: z.infer | null = null; + subflowStates: Record = {}; + toolCallIdMap: Record> = {}; + pendingToolCalls: Record = {}; + pendingToolPermissionRequests: Record> = {}; + pendingAskHumanRequests: Record> = {}; + allowedToolCallIds: Record = {}; + deniedToolCallIds: Record = {}; + + constructor(agentName: string, runId?: string) { + this.agentName = agentName; + this.runId = runId || runIdGenerator.next(); + this.logger = new RunLogger(this.runId); + if (!runId) { + this.logger.log({ + type: "start", + runId: this.runId, + agentName: this.agentName, + subflow: [], + }); + } + } + + getPendingPermissions(): z.infer[] { + const response: z.infer[] = []; + for (const [id, subflowState] of Object.entries(this.subflowStates)) { + for (const perm of subflowState.getPendingPermissions()) { + response.push({ + ...perm, + subflow: [id, ...perm.subflow], + }); + } + } + for (const perm of Object.values(this.pendingToolPermissionRequests)) { + response.push({ + ...perm, + subflow: [], + }); + } + return response; + } + + getPendingAskHumans(): z.infer[] { + const response: z.infer[] = []; + for (const [id, subflowState] of Object.entries(this.subflowStates)) { + for (const ask of subflowState.getPendingAskHumans()) { + response.push({ + ...ask, + subflow: [id, ...ask.subflow], + }); + } + } + for (const ask of Object.values(this.pendingAskHumanRequests)) { + response.push({ + ...ask, + subflow: [], + }); + } + return response; + } + + finalResponse(): string { + if (!this.lastAssistantMsg) { + return ''; + } + if (typeof this.lastAssistantMsg.content === "string") { + return this.lastAssistantMsg.content; + } + return this.lastAssistantMsg.content.reduce((acc, part) => { + if (part.type === "text") { + return acc + part.text; + } + return acc; + }, ""); + } + + ingest(event: z.infer) { + if (event.subflow.length > 0) { + const { subflow, ...rest } = event; + this.subflowStates[subflow[0]].ingest({ + ...rest, + subflow: subflow.slice(1), + }); + return; + } + switch (event.type) { + case "message": + this.messages.push(event.message); + if (event.message.content instanceof Array) { + for (const part of event.message.content) { + if (part.type === "tool-call") { + this.toolCallIdMap[part.toolCallId] = part; + this.pendingToolCalls[part.toolCallId] = true; + } + } + } + if (event.message.role === "tool") { + const message = event.message as z.infer; + delete this.pendingToolCalls[message.toolCallId]; + } + if (event.message.role === "assistant") { + this.lastAssistantMsg = event.message; + } + break; + case "spawn-subflow": + this.subflowStates[event.toolCallId] = new AgentState(event.agentName); + break; + case "tool-permission-request": + this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event; + break; + case "tool-permission-response": + switch (event.response) { + case "approve": + this.allowedToolCallIds[event.toolCallId] = true; + break; + case "deny": + this.deniedToolCallIds[event.toolCallId] = true; + break; + } + delete this.pendingToolPermissionRequests[event.toolCallId]; + break; + case "ask-human-request": + this.pendingAskHumanRequests[event.toolCallId] = event; + break; + case "ask-human-response": + // console.error('im here', this.agentName, this.runId, event.subflow); + const ogEvent = this.pendingAskHumanRequests[event.toolCallId]; + this.messages.push({ + role: "tool", + content: JSON.stringify({ + userResponse: event.response, + }), + toolCallId: ogEvent.toolCallId, + toolName: this.toolCallIdMap[ogEvent.toolCallId]!.toolName, + }); + delete this.pendingAskHumanRequests[ogEvent.toolCallId]; + break; + } + } + + ingestAndLog(event: z.infer) { + this.ingest(event); + this.logger!.log(event); + } + + *ingestAndLogAndYield(event: z.infer): Generator, void, unknown> { + this.ingestAndLog(event); + yield event; + } +} + +export async function* streamAgent(state: AgentState): AsyncGenerator, void, unknown> { + // set up agent + const agent = await loadAgent(state.agentName); + + // set up tools + const tools = await buildTools(agent); + + // set up provider + model const provider = getProvider(agent.provider); const model = provider(agent.model || ModelConfig.defaults.model); + let loopCounter = 0; - // run one turn while (true) { + // console.error(`loop counter: ${loopCounter++}`) + // if last response is from assistant and text, so exit + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage + && lastMessage.role === "assistant" + && (typeof lastMessage.content === "string" + || !lastMessage.content.some(part => part.type === "tool-call") + ) + ) { + // console.error("Nothing to do, exiting (a.)") + return; + } + + // execute any pending tool calls + for (const toolCallId of Object.keys(state.pendingToolCalls)) { + const toolCall = state.toolCallIdMap[toolCallId]; + + // if ask-human, skip + if (toolCall.toolName === "ask-human") { + continue; + } + + // if tool has been denied, deny + if (state.deniedToolCallIds[toolCallId]) { + yield* state.ingestAndLogAndYield({ + type: "message", + message: { + role: "tool", + content: "Unable to execute this tool: Permission was denied.", + toolCallId: toolCallId, + toolName: toolCall.toolName, + }, + subflow: [], + }); + continue; + } + + // if permission is pending on this tool call, allow execution + if (state.pendingToolPermissionRequests[toolCallId]) { + continue; + } + + // execute approved tool + yield* state.ingestAndLogAndYield({ + type: "tool-invocation", + toolName: toolCall.toolName, + input: JSON.stringify(toolCall.arguments), + subflow: [], + }); + let result: any = null; + if (agent.tools![toolCall.toolName].type === "agent") { + let subflowState = state.subflowStates[toolCallId]; + for await (const event of streamAgent(subflowState)) { + yield* state.ingestAndLogAndYield({ + ...event, + subflow: [toolCallId, ...event.subflow], + }); + } + if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { + result = subflowState.finalResponse(); + } + } else { + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments); + } + if (result) { + const resultMsg: z.infer = { + role: "tool", + content: JSON.stringify(result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; + yield* state.ingestAndLogAndYield({ + type: "tool-result", + toolName: toolCall.toolName, + result: result, + subflow: [], + }); + yield* state.ingestAndLogAndYield({ + type: "message", + message: resultMsg, + subflow: [], + }); + } + } + + // if pending state, exit + if (state.getPendingAskHumans().length || state.getPendingPermissions().length) { + // console.error("pending asks or permissions, exiting (b.)") + return; + } + + // if current message state isn't runnable, exit + if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") { + // console.error("current message state isn't runnable, exiting (c.)") + return; + } + + // run one LLM turn. // stream agent response and build message const messageBuilder = new StreamStepMessageBuilder(); for await (const event of streamLlm( model, - messages, + state.messages, agent.instructions, tools, )) { messageBuilder.ingest(event); - yield { - type: "stream-event", + yield* state.ingestAndLogAndYield({ + type: "llm-stream-event", event: event, - }; - } - - // build and emit final message from agent response - const msg = messageBuilder.get(); - normaliseAskHumanToolCall(msg); - messages.push(msg); - yield { - type: "message", - message: msg, - }; - - // handle tool calls - const mappedToolCalls: z.infer[] = []; - let msgToolCallParts: z.infer[] = []; - if (msg.content instanceof Array) { - msgToolCallParts = msg.content.filter(part => part.type === "tool-call"); - } - const hasToolCalls = msgToolCallParts.length > 0; - - // validate and map tool calls - for (const part of msgToolCallParts) { - const agentTool = tools[part.toolName]; - if (!agentTool) { - throw new Error(`Tool ${part.toolName} not found`); - } - mappedToolCalls.push({ - toolCall: part, - agentTool: agent.tools![part.toolName], + subflow: [], }); } - // first, handle tool calls other than ask-human - for (const call of mappedToolCalls) { - if (call.toolCall.toolName === "ask-human") { - continue; + // build and emit final message from agent response + const message = messageBuilder.get(); + yield* state.ingestAndLogAndYield({ + type: "message", + message, + subflow: [], + }); + + // if there were any ask-human calls, emit those events + if (message.content instanceof Array) { + for (const part of message.content) { + if (part.type === "tool-call") { + const underlyingTool = agent.tools![part.toolName]; + if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") { + yield* state.ingestAndLogAndYield({ + type: "ask-human-request", + toolCallId: part.toolCallId, + query: part.arguments.question, + subflow: [], + }); + } + if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") { + yield *state.ingestAndLogAndYield({ + type: "tool-permission-request", + toolCall: part, + subflow: [], + }); + } + if (underlyingTool.type === "agent" && underlyingTool.name) { + yield* state.ingestAndLogAndYield({ + type: "spawn-subflow", + agentName: underlyingTool.name, + toolCallId: part.toolCallId, + subflow: [], + }); + yield* state.ingestAndLogAndYield({ + type: "message", + message: { + role: "user", + content: part.arguments.message, + }, + subflow: [part.toolCallId], + }); + } + } } - const { agentTool, toolCall } = call; - yield { - type: "tool-invocation", - toolName: toolCall.toolName, - input: JSON.stringify(toolCall.arguments), - }; - const result = await execTool(agentTool, toolCall.arguments); - const resultMsg: z.infer = { - role: "tool", - content: JSON.stringify(result), - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - }; - messages.push(resultMsg); - yield { - type: "tool-result", - toolName: toolCall.toolName, - result: result, - }; - yield { - type: "message", - message: resultMsg, - }; } - - // then, handle ask-human (only first one) - const askHumanCall = mappedToolCalls.filter(call => call.toolCall.toolName === "ask-human")[0]; - if (askHumanCall) { - yield { - type: "pause-for-human-input", - toolCallId: askHumanCall.toolCall.toolCallId, - question: askHumanCall.toolCall.arguments.question as string, - }; - return; - } - - // if the agent response had tool calls, replay this agent - if (hasToolCalls) { - continue; - } - - // otherwise, break - return; } } diff --git a/apps/cli/src/application/lib/exec-tool.ts b/apps/cli/src/application/lib/exec-tool.ts index 5ed146ce..ddd05e52 100644 --- a/apps/cli/src/application/lib/exec-tool.ts +++ b/apps/cli/src/application/lib/exec-tool.ts @@ -6,10 +6,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { Client } from "@modelcontextprotocol/sdk/client"; -import { AssistantMessage } from "../entities/message.js"; import { BuiltinTools } from "./builtin-tools.js"; -import { loadAgent, streamAgentTurn } from "./agent.js"; -import { app } from "@/app.js"; async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: any): Promise { // load mcp configuration from the tool @@ -54,49 +51,10 @@ async function execMcpTool(agentTool: z.infer & { type: " return result; } -async function execAgentTool(agentTool: z.infer & { type: "agent" }, input: any): Promise { - let lastMsg: z.infer | null = null; - const agent = await loadAgent(agentTool.name); - for await (const event of streamAgentTurn({ - agent, - messages: [{ - role: "user", - content: JSON.stringify(input), - }], - })) { - if (event.type === "message" && event.message.role === "assistant") { - lastMsg = event.message; - } - if (event.type === "pause-for-human-input") { - return `I need more information from a human in order to continue. I should use the ask-human tool to ask the user for a response on the question below. Once the user comes back with an answer, call this tool again with the answer embedded in the original input that you used to call this tool the first time. - - Question: ${event.question}`; - } - if (event.type === "error") { - throw new Error(event.error); - } - } - - if (!lastMsg) { - throw new Error("No message received from agent"); - } - if (typeof lastMsg.content === "string") { - return lastMsg.content; - } - return lastMsg.content.reduce((acc, part) => { - if (part.type === "text") { - acc += part.text; - } - return acc; - }, ""); -} - export async function execTool(agentTool: z.infer, input: any): Promise { switch (agentTool.type) { case "mcp": return execMcpTool(agentTool, input); - case "agent": - return execAgentTool(agentTool, input); case "builtin": const builtinTool = BuiltinTools[agentTool.name]; if (!builtinTool || !builtinTool.execute) { diff --git a/apps/cli/src/application/lib/stream-renderer.ts b/apps/cli/src/application/lib/stream-renderer.ts index ebcc92b7..30c4dcf5 100644 --- a/apps/cli/src/application/lib/stream-renderer.ts +++ b/apps/cli/src/application/lib/stream-renderer.ts @@ -28,14 +28,10 @@ export class StreamRenderer { render(event: z.infer) { switch (event.type) { case "start": { - this.onStart(event.agent, event.runId); + this.onStart(event.agentName, event.runId); break; } - case "step-start": { - this.onStepStart(); - break; - } - case "stream-event": { + case "llm-stream-event": { this.renderLlmEvent(event.event); break; } @@ -51,22 +47,10 @@ export class StreamRenderer { this.onStepToolResult(event.toolName, event.result); break; } - case "step-end": { - this.onStepEnd(); - break; - } - case "end": { - this.onEnd(); - break; - } case "error": { this.onError(event.error); break; } - case "pause-for-human-input": { - this.onPauseForHumanInput(event.toolCallId, event.question); - break; - } } } @@ -99,10 +83,9 @@ export class StreamRenderer { } } - private onStart(agent: string, runId: string) { + private onStart(agentName: string, runId: string) { this.write("\n"); - this.write(this.bold(this.cyan(`╭─ Agent: ${agent}`))); - this.write(this.dim(` │ run ${runId}`)); + this.write(this.bold(`▶ Agent ${agentName} (run ${runId})`)); this.write("\n"); this.write(this.dim(`╰─────────────────────────────────────────────────\n`)); } From f6019a4fde731bf6b046ef5a8b94c32b0d1a5c5e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:38:14 +0530 Subject: [PATCH 35/38] add update-state cmd --- apps/cli/bin/app.js | 53 ++++++++++++++++++++++++++++++--------------- apps/cli/src/app.ts | 45 +++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/cli/bin/app.js b/apps/cli/bin/app.js index 457f2ab7..cdc12ed6 100755 --- a/apps/cli/bin/app.js +++ b/apps/cli/bin/app.js @@ -4,28 +4,29 @@ import { hideBin } from 'yargs/helpers'; import { app } from '../dist/app.js'; yargs(hideBin(process.argv)) + .command( "$0", "Run rowboatx", (y) => y - .option("agent", { - type: "string", - description: "The agent to run", - default: "copilot", - }) - .option("run_id", { - type: "string", - description: "Continue an existing run", - }) - .option("input", { - type: "string", - description: "The input to the agent", - }) - .option("no-interactive", { - type: "boolean", - description: "Do not interact with the user", - default: false, - }), + .option("agent", { + type: "string", + description: "The agent to run", + default: "copilot", + }) + .option("run_id", { + type: "string", + description: "Continue an existing run", + }) + .option("input", { + type: "string", + description: "The input to the agent", + }) + .option("no-interactive", { + type: "boolean", + description: "Do not interact with the user", + default: false, + }), (argv) => { app({ agent: argv.agent, @@ -35,4 +36,20 @@ yargs(hideBin(process.argv)) }); } ) + .command( + "update-state ", + "Update state for a run", + (y) => y + .positional("agent", { + type: "string", + description: "The agent to run", + }) + .positional("run_id", { + type: "string", + description: "The run id to update", + }), + (argv) => { + updateState(argv.agent, argv.run_id); + } + ) .parse(); \ No newline at end of file diff --git a/apps/cli/src/app.ts b/apps/cli/src/app.ts index 5d2be860..ece4c792 100644 --- a/apps/cli/src/app.ts +++ b/apps/cli/src/app.ts @@ -9,6 +9,27 @@ import { createInterface, Interface } from "node:readline/promises"; import { ToolCallPart } from "./application/entities/message.js"; import { z } from "zod"; +export async function updateState(agent: string, runId: string) { + const state = new AgentState(agent, runId); + // If running in a TTY, read run events from stdin line-by-line + if (!input.isTTY) { + return; + } + + const rl = createInterface({ input, crlfDelay: Infinity }); + try { + for await (const line of rl) { + if (line.trim() === "") { + continue; + } + const event = RunEvent.parse(JSON.parse(line)); + state.ingestAndLog(event); + } + } finally { + rl.close(); + } +} + export async function app(opts: { agent: string; runId?: string; @@ -16,7 +37,7 @@ export async function app(opts: { noInteractive?: boolean; }) { const renderer = new StreamRenderer(); - const state = new AgentState(opts.agent); + const state = new AgentState(opts.agent, opts.runId); // load existing and assemble state if required let runId = opts.runId; @@ -45,11 +66,15 @@ export async function app(opts: { if (!opts.noInteractive) { rl = createInterface({ input, output }); } + let inputConsumed = false; try { while (true) { // ask for pending tool permissions for (const perm of Object.values(state.getPendingPermissions())) { + if (opts.noInteractive) { + return; + } const response = await getToolCallPermission(perm.toolCall, rl!); state.ingestAndLog({ type: "tool-permission-response", @@ -61,6 +86,9 @@ export async function app(opts: { // ask for pending human input for (const ask of Object.values(state.getPendingAskHumans())) { + if (opts.noInteractive) { + return; + } const response = await getAskHumanResponse(ask.query, rl!); state.ingestAndLog({ type: "ask-human-response", @@ -80,6 +108,21 @@ export async function app(opts: { // if nothing pending, get user input if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) { + if (opts.input && !inputConsumed) { + state.ingestAndLog({ + type: "message", + message: { + role: "user", + content: opts.input, + }, + subflow: [], + }); + inputConsumed = true; + continue; + } + if (opts.noInteractive) { + return; + } const response = await getUserInput(rl!); state.ingestAndLog({ type: "message", From 150f23ab90ad385e01663fbd572d1aede76ce5f8 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:54:54 +0530 Subject: [PATCH 36/38] wire up bash allowlist --- apps/cli/src/application/lib/agent.ts | 14 +++++---- .../src/application/lib/command-executor.ts | 29 +++++-------------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/application/lib/agent.ts b/apps/cli/src/application/lib/agent.ts index 823ad3b3..d4aa737d 100644 --- a/apps/cli/src/application/lib/agent.ts +++ b/apps/cli/src/application/lib/agent.ts @@ -13,6 +13,7 @@ import { execTool } from "./exec-tool.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js"; import { BuiltinTools } from "./builtin-tools.js"; import { CopilotAgent } from "../assistant/agent.js"; +import { isBlocked } from "./command-executor.js"; export async function mapAgentTool(t: z.infer): Promise { switch (t.type) { @@ -552,11 +553,14 @@ export async function* streamAgent(state: AgentState): AsyncGenerator !allowSet.has(cmd)); } -function enforceSecurity(command: string): CommandResult | null { +// export const BlockedResult = { +// stdout: '', +// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`, +// exitCode: 126, +// }; + +export function isBlocked(command: string): boolean { const blocked = findBlockedCommands(command); - - if (!blocked.length) { - return null; - } - - return { - stdout: '', - stderr: `Command blocked by security policy. Blocked command(s): ${blocked.join(', ')}. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`, - exitCode: 126, - }; + return blocked.length > 0; } export interface CommandResult { @@ -89,11 +86,6 @@ export async function executeCommand( maxBuffer?: number; // max buffer size in bytes } ): Promise { - const securityResult = enforceSecurity(command); - if (securityResult) { - return securityResult; - } - try { const { stdout, stderr } = await execPromise(command, { cwd: options?.cwd, @@ -128,11 +120,6 @@ export function executeCommandSync( timeout?: number; } ): CommandResult { - const securityResult = enforceSecurity(command); - if (securityResult) { - return securityResult; - } - try { const stdout = execSync(command, { cwd: options?.cwd, From d4717c9015a5a448508ec20a8caf94704ddd6d14 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:12:54 +0530 Subject: [PATCH 37/38] update allowed cmds --- apps/cli/src/application/config/security.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/application/config/security.ts b/apps/cli/src/application/config/security.ts index 35809f14..c2b4f9fb 100644 --- a/apps/cli/src/application/config/security.ts +++ b/apps/cli/src/application/config/security.ts @@ -4,7 +4,18 @@ import { WorkDir } from "./config.js"; export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"); -const DEFAULT_ALLOW_LIST = ["ls", "pwd", "cat", "echo", "whoami"]; +const DEFAULT_ALLOW_LIST = [ + "cat", + "curl", + "date", + "echo", + "grep", + "jq", + "ls", + "pwd", + "yq", + "whoami" +] let cachedAllowList: string[] | null = null; let cachedMtimeMs: number | null = null; From da5f64e9380ad111d35c52a3534ca67e965c65ab Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:10:26 +0530 Subject: [PATCH 38/38] bump version --- apps/cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 23991bc6..530c08c5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@rowboatlabs/rowboatx", - "version": "0.3.0", + "version": "0.6.0", "main": "index.js", "type": "module", "scripts": { @@ -17,7 +17,7 @@ }, "keywords": [], "author": "Rowboat Labs", - "license": "MIT", + "license": "Apache-2.0", "description": "", "devDependencies": { "@types/node": "^24.9.1",