From 18d2bc5b87be3f90199e5bef87cc03619f059846 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 00:59:21 +0200 Subject: [PATCH 1/3] feat: run workflow agents from personas --- README.md | 5 + package-lock.json | 789 +++++++++++++++++- packages/core/package.json | 2 + .../builder-agent-round-trip.test.ts | 14 +- .../src/__tests__/persona-runtime.test.ts | 14 + .../src/__tests__/workflow-runner.test.ts | 90 +- packages/core/src/builder.ts | 9 +- packages/core/src/persona-runtime.ts | 76 ++ packages/core/src/process-backend-executor.ts | 5 + packages/core/src/process-spawner.ts | 5 + packages/core/src/runner.ts | 146 +++- packages/core/src/schema.json | 13 +- packages/core/src/schema.ts | 6 +- 13 files changed, 1125 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/__tests__/persona-runtime.test.ts create mode 100644 packages/core/src/persona-runtime.ts diff --git a/README.md b/README.md index 5406fc5..2a25b25 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ agents: role: "Test engineer" interactive: false # Non-interactive: runs as subprocess, no PTY/messaging + # A persona replaces cli + role. Its harness, model, standing instructions, + # installed skills, MCP servers, and harness settings come from the spec. + - name: integrations + persona: nango-integrations + workflows: - name: build-and-test onError: retry # fail | skip | retry diff --git a/package-lock.json b/package-lock.json index 2d9ecb8..3297ce9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "relayflows", - "version": "0.1.0", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relayflows", - "version": "0.1.0", + "version": "1.0.5", "workspaces": [ "packages/core", "packages/cli", @@ -170,6 +170,27 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@agentworkforce/persona-kit": { + "version": "4.1.38", + "dependencies": { + "@relaycast/a2a": "^6.2.0", + "@relayfile/adapter-core": "^0.5.1", + "@relayfile/local-mount": "^0.10.23" + } + }, + "node_modules/@agentworkforce/persona-registry": { + "version": "4.1.38", + "dependencies": { + "@agentworkforce/persona-kit": "4.1.38", + "@agentworkforce/workload-router": "4.1.38" + } + }, + "node_modules/@agentworkforce/workload-router": { + "version": "4.1.38", + "dependencies": { + "@agentworkforce/persona-kit": "4.1.38" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -872,7 +893,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1304,6 +1324,315 @@ ], "license": "MIT" }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@relaycast/a2a": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@relaycast/a2a/-/a2a-6.3.2.tgz", + "integrity": "sha512-FpajSYqN0sn6+waAmQuHYRr2YR2nMTIRe+epadCDsSm7CiUZxVncY+0CRa1LxCTm79+VTnXfb5TmiFMQcv9+xA==", + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@relaycast/a2a/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@relaycast/sdk": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.2.0.tgz", @@ -1339,6 +1668,27 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@relayfile/adapter-core": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@relayfile/adapter-core/-/adapter-core-0.5.14.tgz", + "integrity": "sha512-N2u8up2smroGmlXM1lcwJRAsbNe78Lw+R76YzolyHOjOOa7Va5JxTpQUX+mqAX5hRwhDiYLElSIjMIh/Ce7l7w==", + "license": "Apache-2.0", + "dependencies": { + "@scalar/postman-to-openapi": "^0.6.0", + "cheerio": "^1.2.0", + "minimatch": "^10.0.3", + "yaml": "^2.8.1" + }, + "bin": { + "adapter-core": "dist/src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, "node_modules/@relayfile/core": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.8.0.tgz", @@ -1348,6 +1698,19 @@ "node": ">=18" } }, + "node_modules/@relayfile/local-mount": { + "version": "0.10.39", + "resolved": "https://registry.npmjs.org/@relayfile/local-mount/-/local-mount-0.10.39.tgz", + "integrity": "sha512-+kNlnf5NVdaKKzdPkW1Jtq4LRB2LdfXxn6bbrBtytG9+muD4lrVyFzMo/UBTq51213o80a7hI+RXZQM3rPwD6w==", + "license": "Apache-2.0", + "dependencies": { + "@parcel/watcher": "^2.5.6", + "ignore": "^7.0.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@relayfile/sdk": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.8.0.tgz", @@ -1771,6 +2134,37 @@ "win32" ] }, + "node_modules/@scalar/helpers": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.5.1.tgz", + "integrity": "sha512-9VvPfv8b+YZVIFwR3SWeq4Y8ij/kU3/kf2M6NKcbf2iVyh63d8s0ssap5m/nOhiz/Puidv/29MAJlJCA0LRssA==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.7.0.tgz", + "integrity": "sha512-kN0PwlJW0de4bwQ4ib+mBHzKJUvBCyR/gwU4zLEq6SCbj+GfgYUh+2a0/yl1WYVUiSkkwFsHjfmQ8KjhR3HK0Q==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/postman-to-openapi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@scalar/postman-to-openapi/-/postman-to-openapi-0.6.3.tgz", + "integrity": "sha512-Y/tMuRZG34wEfpTxDfXFp5o2X3ibb5ojGWupGJ9ZxkThCx7rOGydnszJPzEbgDK3eF6nJ6UuE7bCTpIEutYnPw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.1", + "@scalar/openapi-types": "0.7.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -2571,6 +2965,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -2581,12 +2984,30 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -2662,6 +3083,48 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2755,6 +3218,34 @@ "node": ">=10.0.0" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2791,6 +3282,70 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2811,6 +3366,31 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3164,6 +3744,37 @@ "node": ">= 0.4" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3177,6 +3788,18 @@ "node": ">= 6" } }, + "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/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3192,6 +3815,15 @@ "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", "license": "MIT" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -3207,6 +3839,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3382,6 +4026,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3435,6 +4094,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -3506,6 +4183,55 @@ "node": ">=8" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -3548,7 +4274,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3726,8 +4451,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/siginfo": { "version": "2.0.0", @@ -3996,6 +4720,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4151,6 +4884,28 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4277,7 +5032,7 @@ }, "packages/browser-primitive": { "name": "@relayflows/browser-primitive", - "version": "0.1.0", + "version": "1.0.5", "dependencies": { "@agent-relay/sdk": "^8.2.0", "playwright": "^1.51.1" @@ -4531,9 +5286,9 @@ }, "packages/cli": { "name": "@relayflows/cli", - "version": "0.1.0", + "version": "1.0.5", "dependencies": { - "@relayflows/core": "0.1.0", + "@relayflows/core": "1.0.5", "commander": "^12.1.0" }, "bin": { @@ -4546,18 +5301,20 @@ }, "packages/core": { "name": "@relayflows/core", - "version": "0.1.0", + "version": "1.0.5", "dependencies": { "@agent-relay/cloud": "^8.2.0", "@agent-relay/config": "^8.2.0", "@agent-relay/harness-driver": "^8.2.0", "@agent-relay/harnesses": "^8.2.0", "@agent-relay/sdk": "^8.2.0", + "@agentworkforce/persona-kit": "^4.1.38", + "@agentworkforce/persona-registry": "^4.1.38", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.8.0", - "@relayflows/browser-primitive": "0.1.0", - "@relayflows/github-primitive": "0.1.0", - "@relayflows/slack-primitive": "0.1.0", + "@relayflows/browser-primitive": "1.0.5", + "@relayflows/github-primitive": "1.0.5", + "@relayflows/slack-primitive": "1.0.5", "@sinclair/typebox": "^0.34.48", "agent-trajectories": "^0.6.0", "chalk": "^4.1.2", @@ -4575,7 +5332,7 @@ }, "packages/github-primitive": { "name": "@relayflows/github-primitive", - "version": "0.1.0", + "version": "1.0.5", "devDependencies": { "@types/node": "^22.19.3", "typescript": "^5.9.3", @@ -4822,12 +5579,12 @@ }, "packages/slack-primitive": { "name": "@relayflows/slack-primitive", - "version": "0.1.0", + "version": "1.0.5", "dependencies": { "@slack/web-api": "^7.16.0" }, "devDependencies": { - "@relayflows/github-primitive": "0.1.0", + "@relayflows/github-primitive": "1.0.5", "@types/node": "^22.19.3", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/packages/core/package.json b/packages/core/package.json index f4259c6..d845471 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,6 +57,8 @@ "@agent-relay/harness-driver": "^8.2.0", "@agent-relay/harnesses": "^8.2.0", "@agent-relay/sdk": "^8.2.0", + "@agentworkforce/persona-kit": "^4.1.38", + "@agentworkforce/persona-registry": "^4.1.38", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.8.0", "@relayflows/browser-primitive": "1.0.5", diff --git a/packages/core/src/__tests__/builder-agent-round-trip.test.ts b/packages/core/src/__tests__/builder-agent-round-trip.test.ts index 7272b58..cc81e30 100644 --- a/packages/core/src/__tests__/builder-agent-round-trip.test.ts +++ b/packages/core/src/__tests__/builder-agent-round-trip.test.ts @@ -43,7 +43,7 @@ describe('WorkflowBuilder.agent()', () => { }, ], skills: 'Review TypeScript changes for correctness.', - } satisfies Omit, 'cwd'>; + } satisfies Omit, 'cwd' | 'persona'>; const { name, constraints, ...options } = input; const config = workflow('agent-round-trip') @@ -64,6 +64,18 @@ describe('WorkflowBuilder.agent()', () => { expect(config.agents[0]?.workdir).toBeUndefined(); }); + it('accepts a persona in place of cli and role', () => { + const config = workflow('persona-agent') + .agent('integrations', { persona: 'nango-integrations' }) + .step('sync', { agent: 'integrations', task: 'Fix the failed sync' }) + .toConfig(); + + expect(config.agents[0]).toEqual({ + name: 'integrations', + persona: 'nango-integrations', + }); + }); + it('rejects mutually exclusive cwd and workdir options', () => { expect(() => workflow('invalid-agent').agent('worker', { diff --git a/packages/core/src/__tests__/persona-runtime.test.ts b/packages/core/src/__tests__/persona-runtime.test.ts new file mode 100644 index 0000000..88f6b6d --- /dev/null +++ b/packages/core/src/__tests__/persona-runtime.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveWorkflowPersona } from '../persona-runtime.js'; + +describe('Relayflow persona runtime', () => { + it('resolves a built-in through the Workforce registry and preserves its runtime', () => { + const persona = resolveWorkflowPersona('persona-maker', process.cwd()); + + expect(persona.resolved.spec.id).toBe('persona-maker'); + expect(persona.cli).toBeTruthy(); + expect(persona.model).toBe(persona.resolved.selection.model); + expect(persona.plan.mount).toBeDefined(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-runner.test.ts b/packages/core/src/__tests__/workflow-runner.test.ts index f1b33ac..e1baefb 100644 --- a/packages/core/src/__tests__/workflow-runner.test.ts +++ b/packages/core/src/__tests__/workflow-runner.test.ts @@ -107,6 +107,9 @@ function emitMockEvent(event: string, payload: any = {}): void { case 'agentSpawned': broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' }; break; + case 'workerReady': + broker = { kind: 'worker_ready', name: payload.name, runtime: payload.runtime ?? 'pty' }; + break; case 'agentReleased': broker = { kind: 'agent_released', name: payload.name }; break; @@ -168,6 +171,25 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => { }; }); +const personaRuntimeMocks = vi.hoisted(() => ({ + dispose: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../persona-runtime.js', () => ({ + resolveWorkflowPersona: vi.fn((reference: string) => ({ + resolved: { spec: { id: reference } }, + plan: {}, + cli: 'codex', + model: 'openai-codex/persona-model', + args: ['--persona-runtime'], + env: { PERSONA_ENV: 'enabled' }, + })), + activateWorkflowPersona: vi.fn(async (persona: Record) => ({ + ...persona, + cwd: '/tmp/relayflow-persona-runtime', + dispose: personaRuntimeMocks.dispose, + })), +})); + // Import after mocking const { WorkflowRunner } = await import('../runner.js'); const { HarnessDriverClient } = await import('@agent-relay/harness-driver'); @@ -365,7 +387,7 @@ agents: ).not.toThrow(); }); - it('should reject agent without cli', () => { + it('should reject agent without cli or persona', () => { expect(() => runner.validateConfig({ version: '1', @@ -373,7 +395,29 @@ agents: swarm: { pattern: 'dag' }, agents: [{ name: 'a' }], }) - ).toThrow('each agent must have a string "cli"'); + ).toThrow('each agent must have exactly one of string "cli" or "persona"'); + }); + + it('should accept persona in place of cli and role', () => { + expect(() => + runner.validateConfig({ + version: '1', + name: 'x', + swarm: { pattern: 'dag' }, + agents: [{ name: 'a', persona: 'nango-integrations' }], + }) + ).not.toThrow(); + }); + + it('should reject agents that set both cli and persona', () => { + expect(() => + runner.validateConfig({ + version: '1', + name: 'x', + swarm: { pattern: 'dag' }, + agents: [{ name: 'a', cli: 'codex', persona: 'nango-integrations' }], + }) + ).toThrow('each agent must have exactly one of string "cli" or "persona"'); }); it('should detect unknown dependencies in workflows', () => { @@ -441,6 +485,48 @@ agents: // ── Execution ────────────────────────────────────────────────────────── describe('execute', () => { + it('spawns a persona with its declared runtime and verifies readiness plus registration', async () => { + mockRelayInstance.spawnPty.mockImplementation(async (input: { name: string; task?: string }) => { + mockRelayInstance.listAgents.mockResolvedValueOnce([{ name: input.name }]); + queueMicrotask(() => { + emitMockEvent('workerReady', { name: input.name }); + emitMockEvent('workerOutput', { name: input.name, chunk: 'STEP_COMPLETE:persona-step\n' }); + }); + return makeMockHandle(input.name); + }); + + const config = { + version: '1', + name: 'persona-workflow', + swarm: { pattern: 'dag' }, + agents: [{ name: 'integration-expert', persona: 'nango-integrations' }], + workflows: [ + { + name: 'default', + steps: [ + { name: 'persona-step', agent: 'integration-expert', task: 'Fix the failed sync' }, + ], + }, + ], + trajectories: false, + } satisfies RelayYamlConfig; + + const run = await runner.execute(config, 'default'); + + expect(run.status).toBe('completed'); + expect(mockRelayInstance.spawnPty).toHaveBeenCalledWith( + expect.objectContaining({ + cli: 'codex', + model: 'openai-codex/persona-model', + args: ['--persona-runtime'], + cwd: '/tmp/relayflow-persona-runtime', + env: expect.objectContaining({ PERSONA_ENV: 'enabled' }), + task: expect.stringContaining('Fix the failed sync'), + }) + ); + expect(personaRuntimeMocks.dispose).toHaveBeenCalledOnce(); + }); + it('should create run and steps in DB', async () => { const config = makeConfig(); const run = await runner.execute(config, 'default'); diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index c560911..b45d67e 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -36,8 +36,7 @@ import type { VariableContext } from './template-resolver.js'; // ── Option types for the builder API ──────────────────────────────────────── -export interface AgentOptions { - cli: AgentCli; +export interface AgentOptionsBase { role?: string; task?: string; channels?: string[]; @@ -88,6 +87,9 @@ export interface AgentOptions { subscriptions?: IntegrationSubscriptionConfig[]; } +export type AgentOptions = AgentOptionsBase & + ({ cli: AgentCli; persona?: never } | { persona: string; cli?: never; role?: never; model?: never }); + /** Options for agent steps (default). */ export interface AgentStepOptions { agent: string; @@ -322,7 +324,8 @@ export class WorkflowBuilder { agent(name: string, options: AgentOptions): this { const def: AgentDefinition = { name, - cli: options.cli, + ...(options.cli ? { cli: options.cli } : {}), + ...(options.persona ? { persona: options.persona } : {}), }; if (options.role !== undefined) def.role = options.role; diff --git a/packages/core/src/persona-runtime.ts b/packages/core/src/persona-runtime.ts new file mode 100644 index 0000000..787f251 --- /dev/null +++ b/packages/core/src/persona-runtime.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { + buildPersonaSpawnPlan, + executePersonaSpawnPlan, + type ExecutionHandle, + type PersonaSpawnPlan, +} from '@agentworkforce/persona-kit'; +import { resolvePersonaReference, type ResolvedPersonaReference } from '@agentworkforce/persona-registry'; + +export interface ResolvedWorkflowPersona { + readonly resolved: ResolvedPersonaReference; + readonly plan: PersonaSpawnPlan; + readonly args: string[]; + readonly cli: string; + readonly model: string; + readonly env: Record; +} + +export interface ActiveWorkflowPersona extends ResolvedWorkflowPersona { + readonly cwd: string; + dispose(): Promise; +} + +/** Resolve through the same cwd/personal/configured/built-in registry as the Workforce CLI. */ +export function resolveWorkflowPersona(reference: string, cwd: string): ResolvedWorkflowPersona { + const resolved = resolvePersonaReference(reference, { cwd }); + const built = buildPersonaSpawnPlan(resolved.selection); + // Relayflow persona launches are always isolated. An empty mount policy is + // still materialized so installed skills and generated sidecars never write + // into the real workflow checkout. + const plan: PersonaSpawnPlan = built.mount + ? built + : { ...built, mount: { ignoredPatterns: [], readonlyPatterns: [] } }; + const args = plan.initialPrompt ? [...plan.args, plan.initialPrompt] : [...plan.args]; + return { + resolved, + plan, + args, + cli: plan.cli, + model: resolved.selection.model, + env: { ...plan.env }, + }; +} + +/** Install skills/materialize config in an isolated mount for one workflow launch. */ +export async function activateWorkflowPersona( + persona: ResolvedWorkflowPersona, + cwd: string +): Promise { + const scratchDir = await mkdtemp(path.join(tmpdir(), 'relayflow-persona-')); + let execution: ExecutionHandle | undefined; + try { + execution = await executePersonaSpawnPlan(persona.plan, { + cwd, + mount: { mountDir: scratchDir, includeGit: true, autoSync: true }, + }); + let disposed = false; + return { + ...persona, + cwd: execution.cwd, + async dispose(): Promise { + if (disposed) return; + disposed = true; + await execution?.dispose(); + await rm(scratchDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await execution?.dispose(); + await rm(scratchDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/core/src/process-backend-executor.ts b/packages/core/src/process-backend-executor.ts index d578fce..ba3d972 100644 --- a/packages/core/src/process-backend-executor.ts +++ b/packages/core/src/process-backend-executor.ts @@ -47,6 +47,11 @@ export function createProcessBackendExecutor( `Route agent "${agentDef.name}" through a subprocess CLI (claude, codex, etc.) or omit processBackend.` ); } + if (!agentDef.cli) { + throw new Error( + `processBackend cannot execute persona agent "${agentDef.name}"; personas require the interactive Relayflow runtime.` + ); + } const extraArgs = agentDef.constraints?.model ? ['--model', agentDef.constraints.model] : []; const argv = buildCommand(agentDef.cli, extraArgs, resolvedTask); diff --git a/packages/core/src/process-spawner.ts b/packages/core/src/process-spawner.ts index ec71e97..54c803c 100644 --- a/packages/core/src/process-spawner.ts +++ b/packages/core/src/process-spawner.ts @@ -217,6 +217,11 @@ async function runCommand(command: SpawnCommand, opts: ShellOpts): Promise { + if (!agent.cli) { + throw new Error( + `ProcessSpawner cannot execute persona agent "${agent.name}" without an activated persona runtime.` + ); + } const extraArgs = agent.constraints?.model ? ['--model', agent.constraints.model] : []; const [bin, ...args] = buildCommand(agent.cli, extraArgs, task); return { bin, args }; diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index ee17545..034f049 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -118,6 +118,11 @@ import type { RunnerStepExecutor, } from './types.js'; import { WorkflowTrajectory, type StepOutcome } from './trajectory.js'; +import { + activateWorkflowPersona, + resolveWorkflowPersona, + type ActiveWorkflowPersona, +} from './persona-runtime.js'; import { runVerification, stripInjectedTaskEcho, @@ -684,6 +689,8 @@ export class WorkflowRunner { private readonly supervisedRuntimeAgents = new Map(); /** Runtime-name lookup for active step agents so channel messages can be attributed to a step. */ private readonly runtimeStepAgents = new Map(); + /** Harnesses that have completed the broker worker_ready handshake. */ + private readonly readyRuntimeAgents = new Set(); /** Per-step completion evidence collected across output, channel, files, and tool side-effects. */ private readonly stepCompletionEvidence = new Map(); /** Expected owner/worker identities per step so coordination signals can be validated by sender. */ @@ -1876,7 +1883,13 @@ export class WorkflowRunner { break; } + case 'worker_ready': { + this.readyRuntimeAgents.add(event.name); + break; + } + case 'agent_exited': { + this.readyRuntimeAgents.delete(event.name); this.lastActivity.delete(event.name); this.lastIdleLog.delete(event.name); if (!this.activeAgentHandles.has(event.name)) { @@ -2839,8 +2852,20 @@ export class WorkflowRunner { if (typeof a.name !== 'string') { throw new Error(`${source}: each agent must have a string "name"`); } - if (typeof a.cli !== 'string') { - throw new Error(`${source}: each agent must have a string "cli"`); + const hasCli = typeof a.cli === 'string' && a.cli.trim().length > 0; + const hasPersona = typeof a.persona === 'string' && a.persona.trim().length > 0; + if (hasCli === hasPersona) { + throw new Error(`${source}: each agent must have exactly one of string "cli" or "persona"`); + } + if (hasPersona && (typeof a.role === 'string' || a.preset !== undefined)) { + throw new Error(`${source}: persona agents replace "cli", "role", and "preset"`); + } + const constraints = a.constraints as Record | undefined; + if (hasPersona && constraints?.model !== undefined) { + throw new Error(`${source}: persona harness and model come from the persona spec`); + } + if (hasPersona && a.interactive === false) { + throw new Error(`${source}: persona agents must run in interactive mode`); } } @@ -3104,7 +3129,7 @@ export class WorkflowRunner { // 4. Build agent summary const agents = resolved.agents.map((a) => ({ name: a.name, - cli: a.cli, + cli: a.cli ?? `persona:${a.persona ?? 'unknown'}`, role: a.role, cwd: a.workdir ? dryRunPaths.get(a.workdir) : a.cwd, stepCount: stepAgentCounts.get(a.name) ?? 0, @@ -6983,6 +7008,7 @@ export class WorkflowRunner { * Explicit fields on the definition always win over preset-inferred defaults. */ private static resolveAgentDef(def: AgentDefinition): AgentDefinition { + if (!def.cli) return def; // Resolve "cursor" alias to whichever cursor agent binary is in PATH const resolvedCli: AgentCli = def.cli === 'cursor' ? resolveCursorCli() : def.cli; @@ -7037,6 +7063,10 @@ export class WorkflowRunner { step: WorkflowStep, timeoutMs?: number ): Promise { + const agentCli = agentDef.cli; + if (!agentCli) { + throw new Error(`Persona agent "${agentDef.name}" cannot run non-interactively`); + } const agentName = `${step.name}-${this.generateShortId()}`; const modelArgs = agentDef.constraints?.model ? ['--model', agentDef.constraints.model] : []; @@ -7064,7 +7094,7 @@ export class WorkflowRunner { '- Output only status messages without the actual deliverable content'; const { cmd, args } = WorkflowRunner.buildNonInteractiveCommand( - agentDef.cli, + agentCli, taskWithDeliverable, modelArgs ); @@ -7075,14 +7105,14 @@ export class WorkflowRunner { const logStream = createWriteStream(logPath, { flags: 'a' }); // Register in workers.json with interactive: false metadata - this.registerWorker(agentName, agentDef.cli, step.task ?? '', undefined, false); + this.registerWorker(agentName, agentCli, step.task ?? '', undefined, false); // Register agent in Relaycast for observability let stopHeartbeat: (() => void) | undefined; if (this.relayApiKey) { const agentClient = await this.registerRelaycastExternalAgent( agentName, - `Non-interactive workflow agent for step "${step.name}" (${agentDef.cli})` + `Non-interactive workflow agent for step "${step.name}" (${agentCli})` ).catch((err) => { console.warn(`[WorkflowRunner] Failed to register ${agentName} in Relaycast:`, err?.message ?? err); return null; @@ -7100,7 +7130,7 @@ export class WorkflowRunner { const proxyMode = await this.resolveAgentProxyMode(agentDef, this.currentConfig); const env = { ...(this.getRelayEnv(proxyMode) ?? filteredEnv()) }; if (proxyMode?.url && proxyMode.token) { - Object.assign(env, resolveProxyEnv(agentDef.cli, proxyMode.url, proxyMode.token)); + Object.assign(env, resolveProxyEnv(agentCli, proxyMode.url, proxyMode.token)); } const agentToken = this.agentTokens.get(agentDef.name); const mount = this.agentMounts.get(agentDef.name); @@ -7128,7 +7158,7 @@ export class WorkflowRunner { exitSignal, } = await new Promise<{ stdout: string; exitCode?: number; exitSignal?: string }>((resolve, reject) => { const spawnEnv = - agentDef.cli === 'opencode' + agentCli === 'opencode' ? { ...env, OPENCODE_PERMISSION: JSON.stringify({ '*': 'allow', external_directory: { '*': 'allow' } }), @@ -7141,7 +7171,7 @@ export class WorkflowRunner { }); // Update workers.json with PID now that we have it - this.registerWorker(agentName, agentDef.cli, step.task ?? '', child.pid, false); + this.registerWorker(agentName, agentCli, step.task ?? '', child.pid, false); // Wire abort signal so runner.abort() kills the child process const abortSignal = this.abortController?.signal; @@ -7216,7 +7246,7 @@ export class WorkflowRunner { return; } - const cliDef = getCliDefinition(agentDef.cli); + const cliDef = getCliDefinition(agentCli); if (code !== 0 && code !== null && !cliDef?.ignoreExitCode) { const stderr = stderrChunks.join(''); reject( @@ -7270,6 +7300,24 @@ export class WorkflowRunner { timeoutMs?: number, options: SpawnAndWaitOptions = {} ): Promise { + const personaResolution = agentDef.persona + ? resolveWorkflowPersona(agentDef.persona, this.resolveExecutionCwd(step, agentDef)) + : undefined; + if (personaResolution) { + agentDef = { + ...agentDef, + cli: personaResolution.cli as AgentCli, + constraints: { + ...(agentDef.constraints ?? {}), + model: personaResolution.model, + }, + }; + } + const agentCli = agentDef.cli; + if (!agentCli) { + throw new Error(`Agent "${agentDef.name}" did not resolve to a launchable CLI`); + } + // Branch: non-interactive agents run as simple subprocesses if (agentDef.interactive === false) { return this.execNonInteractive(agentDef, step, timeoutMs); @@ -7297,21 +7345,21 @@ export class WorkflowRunner { [...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`).test(role)); const pattern = this.currentConfig?.swarm.pattern; const isHubPattern = pattern && WorkflowRunner.HUB_PATTERNS.has(pattern); - const usesHeadlessWorkflowSpawner = agentDef.cli === 'opencode'; + const usesHeadlessWorkflowSpawner = agentCli === 'opencode'; const humanAssistanceConfig = this.resolveHumanAssistanceConfig(step); const humanAssistanceGuidance = this.buildHumanAssistanceGuidance(humanAssistanceConfig); const integrationSubscriptionGuidance = this.buildIntegrationSubscriptionGuidance(agentDef); const delegationGuidance = usesHeadlessWorkflowSpawner || (!isHub && isHubPattern) ? '' - : this.buildDelegationGuidance(agentDef.cli, timeoutMs); + : this.buildDelegationGuidance(agentCli, timeoutMs); // Non-claude CLIs (codex, gemini, etc.) don't auto-register with Relaycast // via the MCP system prompt the way claude does. Inject an explicit preamble // so they call register_agent() before any other relay tool. const relayRegistrationNote = usesHeadlessWorkflowSpawner ? '' - : this.buildRelayRegistrationNote(agentDef.cli, agentName); + : this.buildRelayRegistrationNote(agentCli, agentName); const interactiveTaskBase = step.task ?? ''; const taskWithExit = usesHeadlessWorkflowSpawner @@ -7330,6 +7378,7 @@ export class WorkflowRunner { const preparedTask = this.prepareInteractiveSpawnTask(agentName, taskWithExit); // Register PTY output listener before spawning so we capture everything + this.readyRuntimeAgents.delete(agentName); this.ptyOutputBuffers.set(agentName, []); // Open a log file so `agents:logs ` works for workflow-spawned agents @@ -7363,12 +7412,16 @@ export class WorkflowRunner { let ptyChunks: string[] = []; let agentReleased = false; let completedWithoutSpawnError = false; + let activePersona: ActiveWorkflowPersona | undefined; try { const agentCwd = this.resolveExecutionCwd(step, agentDef); + activePersona = personaResolution + ? await activateWorkflowPersona(personaResolution, agentCwd) + : undefined; const interactiveSpawnPolicy = resolveSpawnPolicy({ AGENT_NAME: agentName, - AGENT_CLI: agentDef.cli, + AGENT_CLI: agentCli, RELAY_API_KEY: this.relayApiKey ?? 'workflow-runner', AGENT_CHANNELS: (agentChannels ?? []).join(','), }); @@ -7376,29 +7429,56 @@ export class WorkflowRunner { const baseEnv = this.getRelayEnv(proxyMode); const proxyEnvOverrides = proxyMode?.url && proxyMode.token - ? resolveProxyEnv(agentDef.cli, proxyMode.url, proxyMode.token) + ? resolveProxyEnv(agentCli, proxyMode.url, proxyMode.token) : undefined; const spawnOptions = { name: agentName, - model: agentDef.constraints?.model, - args: interactiveSpawnPolicy.args, + model: personaResolution?.model ?? agentDef.constraints?.model, + args: personaResolution?.args ?? interactiveSpawnPolicy.args, channels: agentChannels, task: preparedTask.spawnTaskText, idleThresholdSecs: agentDef.constraints?.idleThresholdSecs, - cwd: agentCwd, + cwd: activePersona?.cwd ?? agentCwd, agentToken: this.agentTokens.get(agentDef.name), - env: proxyEnvOverrides ? { ...baseEnv, ...proxyEnvOverrides } : baseEnv, + env: { + ...baseEnv, + ...(personaResolution?.env ?? {}), + ...(proxyEnvOverrides ?? {}), + }, }; - this.log(`[${step.name}] Spawning ${agentDef.cli} (pty)`); + this.log( + `[${step.name}] Spawning ${personaResolution ? `persona ${personaResolution.resolved.spec.id}` : agentCli} (pty)` + ); agent = new WorkflowAgentHandle( await this.withBrokerRecovery(`spawning agent for step "${step.name}"`, (relay) => relay.spawnPty({ ...(spawnOptions as Record), - cli: agentDef.cli, + cli: agentCli, } as SpawnPtyInput) ) ); + if (personaResolution) { + const ready = await this.waitForRuntimeAgentReady( + agent, + Math.min(timeoutMs ?? 90_000, 90_000) + ); + if (ready !== 'ready') { + throw new Error( + `Persona "${personaResolution.resolved.spec.id}" failed harness readiness (${ready})` + ); + } + const registered = await this.withBrokerRecovery( + `verifying broker registration for persona step "${step.name}"`, + (relay) => relay.listAgents() + ); + if (!registered.some((candidate) => candidate.name === agent?.name)) { + throw new Error( + `Persona "${personaResolution.resolved.spec.id}" did not register with the broker` + ); + } + } + // Re-key PTY maps if broker assigned a different name than requested if (agent.name !== agentName) { const oldName = agentName; @@ -7477,13 +7557,13 @@ export class WorkflowRunner { } catch { // Best-effort PID lookup } - this.registerWorker(agentName, agentDef.cli, step.task ?? '', workerPid); + this.registerWorker(agentName, agentCli, step.task ?? '', workerPid); // Register the spawned agent in Relaycast for observability + start heartbeat if (this.relayApiKey) { const agentClient = await this.registerRelaycastExternalAgent( liveAgent.name, - `Workflow agent for step "${step.name}" (${agentDef.cli})` + `Workflow agent for step "${step.name}" (${agentCli})` ).catch((err) => { console.warn( `[WorkflowRunner] Failed to register ${liveAgent.name} in Relaycast:`, @@ -7604,9 +7684,11 @@ export class WorkflowRunner { this.unregisterWorker(agentName); this.supervisedRuntimeAgents.delete(agentName); this.runtimeStepAgents.delete(agentName); + this.readyRuntimeAgents.delete(agentName); if (preparedTask.taskTmpFile) { await unlink(preparedTask.taskTmpFile).catch(() => undefined); } + await activePersona?.dispose(); } let output: string; @@ -7645,6 +7727,19 @@ export class WorkflowRunner { }; } + private async waitForRuntimeAgentReady( + agent: WorkflowAgentHandle, + timeoutMs: number + ): Promise<'ready' | 'exited' | 'timeout'> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.readyRuntimeAgents.has(agent.name)) return 'ready'; + if (agent.exitCode !== undefined || agent.exitSignal !== undefined) return 'exited'; + await sleepMs(25); + } + return this.readyRuntimeAgents.has(agent.name) ? 'ready' : 'timeout'; + } + private resolveRelayfileRuntimeConfig(config: RelayYamlConfig | undefined): RelayfileRuntimeConfig | undefined { const relayfileConfig = config?.integrations?.relayfile; const hasExplicitWorkflowCredentials = Boolean(relayfileConfig?.workspaceId || relayfileConfig?.token); @@ -9849,8 +9944,11 @@ export class WorkflowRunner { if (!agentDef || !cwd || !startedAt) return; try { + const cli = agentDef.cli ?? + (agentDef.persona ? (resolveWorkflowPersona(agentDef.persona, cwd).cli as AgentCli) : undefined); + if (!cli) return; const report = await collectCliSession({ - cli: agentDef.cli, + cli, cwd, startedAt, completedAt, diff --git a/packages/core/src/schema.json b/packages/core/src/schema.json index e4b4f91..bf56b05 100644 --- a/packages/core/src/schema.json +++ b/packages/core/src/schema.json @@ -371,7 +371,7 @@ }, "AgentDefinition": { "type": "object", - "required": ["name", "cli"], + "required": ["name"], "additionalProperties": false, "properties": { "name": { @@ -381,6 +381,10 @@ "cli": { "$ref": "#/definitions/AgentCli" }, + "persona": { + "type": "string", + "description": "AgentWorkforce persona id or JSON path; mutually exclusive with cli" + }, "role": { "type": "string", "description": "Agent role description" @@ -441,7 +445,11 @@ }, "description": "Relayflow integration subscriptions for this agent." } - } + }, + "oneOf": [ + { "required": ["cli"] }, + { "required": ["persona"] } + ] }, "AgentCli": { "type": "string", @@ -451,6 +459,7 @@ "gemini", "aider", "goose", + "grok", "opencode", "droid", "cursor", diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index f493b0a..cee8312 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -47,7 +47,10 @@ export interface AgentCredentialConfig { /** Definition of an agent participating in a workflow. */ export interface AgentDefinition { name: string; - cli: AgentCli; + /** Raw harness launch. Mutually exclusive with `persona` in workflow YAML. */ + cli?: AgentCli; + /** AgentWorkforce persona id or JSON path. Mutually exclusive with `cli`. */ + persona?: string; role?: string; task?: string; channels?: string[]; @@ -94,6 +97,7 @@ export type AgentCli = | 'gemini' | 'aider' | 'goose' + | 'grok' | 'opencode' | 'droid' | 'cursor' From 56d0c24b72435a2ea8a1109ae66477006166f767 Mon Sep 17 00:00:00 2001 From: Miya Date: Sat, 8 Aug 2026 22:16:24 +0200 Subject: [PATCH 2/3] fix: close persona workflow review findings --- README.md | 2 + .../builder-agent-round-trip.test.ts | 7 ++ .../src/__tests__/workflow-runner.test.ts | 42 ++++++++++ packages/core/src/builder.ts | 12 ++- packages/core/src/persona-runtime.ts | 26 ++++-- packages/core/src/runner.ts | 83 ++++++++++++++----- packages/core/src/schema.json | 29 ++++++- packages/core/src/schema.ts | 26 ++++-- packages/core/src/templates.ts | 14 +++- 9 files changed, 200 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 2a25b25..65c5f47 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ agents: # A persona replaces cli + role. Its harness, model, standing instructions, # installed skills, MCP servers, and harness settings come from the spec. + # Persona agents are interactive-only: do not set cli, preset, or + # constraints.model, and do not use interactive: false. - name: integrations persona: nango-integrations diff --git a/packages/core/src/__tests__/builder-agent-round-trip.test.ts b/packages/core/src/__tests__/builder-agent-round-trip.test.ts index cc81e30..cdb0766 100644 --- a/packages/core/src/__tests__/builder-agent-round-trip.test.ts +++ b/packages/core/src/__tests__/builder-agent-round-trip.test.ts @@ -76,6 +76,13 @@ describe('WorkflowBuilder.agent()', () => { }); }); + it('keeps persona-only options unrepresentable in the builder API', () => { + // @ts-expect-error Persona agents cannot use non-interactive CLI presets. + workflow('invalid-persona-preset').agent('integrations', { persona: 'nango-integrations', preset: 'worker' }); + // @ts-expect-error Persona agents are always interactive. + workflow('invalid-persona-interactive').agent('integrations', { persona: 'nango-integrations', interactive: false }); + }); + it('rejects mutually exclusive cwd and workdir options', () => { expect(() => workflow('invalid-agent').agent('worker', { diff --git a/packages/core/src/__tests__/workflow-runner.test.ts b/packages/core/src/__tests__/workflow-runner.test.ts index e1baefb..fab2b20 100644 --- a/packages/core/src/__tests__/workflow-runner.test.ts +++ b/packages/core/src/__tests__/workflow-runner.test.ts @@ -420,6 +420,17 @@ agents: ).toThrow('each agent must have exactly one of string "cli" or "persona"'); }); + it('rejects malformed launch fields instead of treating them as absent', () => { + expect(() => + runner.validateConfig({ + version: '1', + name: 'x', + swarm: { pattern: 'dag' }, + agents: [{ name: 'a', persona: 'nango-integrations', cli: 42 }], + }) + ).toThrow('"cli" must be a non-empty string when provided'); + }); + it('should detect unknown dependencies in workflows', () => { const config = makeConfig({ workflows: [ @@ -527,6 +538,37 @@ agents: expect(personaRuntimeMocks.dispose).toHaveBeenCalledOnce(); }); + it('releases and disposes a persona runtime when readiness never arrives', async () => { + const handle = makeMockHandle('persona-step-failure'); + mockRelayInstance.spawnPty.mockResolvedValue(handle); + const config = { + version: '1', + name: 'persona-readiness-failure', + swarm: { pattern: 'dag' }, + agents: [{ name: 'integration-expert', persona: 'nango-integrations' }], + workflows: [ + { + name: 'default', + steps: [ + { + name: 'persona-step-failure', + agent: 'integration-expert', + task: 'Fix the failed sync', + timeoutMs: 1, + }, + ], + }, + ], + trajectories: false, + } satisfies RelayYamlConfig; + + const run = await runner.execute(config, 'default'); + + expect(run.status).toBe('failed'); + expect(handle.release).toHaveBeenCalledOnce(); + expect(personaRuntimeMocks.dispose).toHaveBeenCalledOnce(); + }); + it('should create run and steps in DB', async () => { const config = makeConfig(); const run = await runner.execute(config, 'default'); diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index b45d67e..75a1acc 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -88,7 +88,17 @@ export interface AgentOptionsBase { } export type AgentOptions = AgentOptionsBase & - ({ cli: AgentCli; persona?: never } | { persona: string; cli?: never; role?: never; model?: never }); + ( + | { cli: AgentCli; persona?: never } + | { + persona: string; + cli?: never; + role?: never; + model?: never; + preset?: never; + interactive?: true; + } + ); /** Options for agent steps (default). */ export interface AgentStepOptions { diff --git a/packages/core/src/persona-runtime.ts b/packages/core/src/persona-runtime.ts index 787f251..7b28153 100644 --- a/packages/core/src/persona-runtime.ts +++ b/packages/core/src/persona-runtime.ts @@ -10,11 +10,14 @@ import { } from '@agentworkforce/persona-kit'; import { resolvePersonaReference, type ResolvedPersonaReference } from '@agentworkforce/persona-registry'; +import { getCliDefinition } from './cli-registry.js'; +import type { AgentCli } from './types.js'; + export interface ResolvedWorkflowPersona { readonly resolved: ResolvedPersonaReference; readonly plan: PersonaSpawnPlan; readonly args: string[]; - readonly cli: string; + readonly cli: AgentCli; readonly model: string; readonly env: Record; } @@ -35,11 +38,16 @@ export function resolveWorkflowPersona(reference: string, cwd: string): Resolved ? built : { ...built, mount: { ignoredPatterns: [], readonlyPatterns: [] } }; const args = plan.initialPrompt ? [...plan.args, plan.initialPrompt] : [...plan.args]; + if (plan.cli === 'api' || !getCliDefinition(plan.cli)) { + throw new Error( + `Persona "${resolved.spec.id}" resolves to unsupported interactive CLI "${plan.cli}"` + ); + } return { resolved, plan, args, - cli: plan.cli, + cli: plan.cli as AgentCli, model: resolved.selection.model, env: { ...plan.env }, }; @@ -64,13 +72,19 @@ export async function activateWorkflowPersona( async dispose(): Promise { if (disposed) return; disposed = true; - await execution?.dispose(); - await rm(scratchDir, { recursive: true, force: true }); + try { + await execution?.dispose(); + } finally { + await rm(scratchDir, { recursive: true, force: true }); + } }, }; } catch (error) { - await execution?.dispose(); - await rm(scratchDir, { recursive: true, force: true }); + try { + await execution?.dispose(); + } finally { + await rm(scratchDir, { recursive: true, force: true }); + } throw error; } } diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 034f049..3e691ef 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -308,6 +308,10 @@ interface SpawnResult { exitCode?: number; exitSignal?: string; promptTaskText?: string; + /** Actual harness runtime location, which may be a persona's isolated mount. */ + runtimeCwd?: string; + /** Resolved CLI used for this launch. */ + cli?: AgentCli; } /** Error carrying exit code/signal from a failed subprocess spawn. */ @@ -2854,6 +2858,12 @@ export class WorkflowRunner { } const hasCli = typeof a.cli === 'string' && a.cli.trim().length > 0; const hasPersona = typeof a.persona === 'string' && a.persona.trim().length > 0; + if (a.cli !== undefined && !hasCli) { + throw new Error(`${source}: "cli" must be a non-empty string when provided`); + } + if (a.persona !== undefined && !hasPersona) { + throw new Error(`${source}: "persona" must be a non-empty string when provided`); + } if (hasCli === hasPersona) { throw new Error(`${source}: each agent must have exactly one of string "cli" or "persona"`); } @@ -4505,23 +4515,30 @@ export class WorkflowRunner { const explicitName = errorHandling?.repairAgent?.trim(); if (explicitName) { const explicitAgent = agentMap.get(explicitName); - if (explicitAgent) return WorkflowRunner.resolveAgentDef(explicitAgent); + if (explicitAgent) { + if (!explicitAgent.cli) { + throw new Error(`repairAgent "${explicitName}" must be a raw CLI agent; personas are interactive-only`); + } + return WorkflowRunner.resolveAgentDef(explicitAgent); + } this.log(`[${step.name}] repairAgent "${explicitName}" not found; falling back to workflow agents`); } if (step.agent) { const stepAgent = agentMap.get(step.agent); - if (stepAgent) return WorkflowRunner.resolveAgentDef(stepAgent); + if (stepAgent?.cli) return WorkflowRunner.resolveAgentDef(stepAgent); } for (const dependency of [...(step.dependsOn ?? [])].reverse()) { const dependencyAgent = stepStates.get(dependency)?.row.agentName; if (!dependencyAgent) continue; const agent = agentMap.get(dependencyAgent); - if (agent) return WorkflowRunner.resolveAgentDef(agent); + if (agent?.cli) return WorkflowRunner.resolveAgentDef(agent); } - const candidates = [...agentMap.values()].map((agent) => WorkflowRunner.resolveAgentDef(agent)); + const candidates = [...agentMap.values()] + .filter((agent): agent is Extract => Boolean(agent.cli)) + .map((agent) => WorkflowRunner.resolveAgentDef(agent)); candidates.sort((a, b) => this.scoreRepairAgent(b) - this.scoreRepairAgent(a)); return candidates[0]; } @@ -5113,6 +5130,7 @@ export class WorkflowRunner { let lastAttemptStartedAt: number | undefined; let lastEffectiveAgentDef: AgentDefinition | undefined; let lastEffectiveCwd: string | undefined; + let lastEffectiveCli: AgentCli | undefined; let lastAttemptReportCaptured = false; let lastDiagnosticResult: DiagnosticResult | null = null; @@ -5137,6 +5155,7 @@ export class WorkflowRunner { lastAttemptStartedAt = undefined; lastEffectiveAgentDef = undefined; lastEffectiveCwd = undefined; + lastEffectiveCli = undefined; lastAttemptReportCaptured = false; let stepOutputForDiagnostic = ''; @@ -5365,6 +5384,10 @@ export class WorkflowRunner { : undefined, }); const output = typeof spawnResult === 'string' ? spawnResult : spawnResult.output; + if (typeof spawnResult !== 'string') { + lastEffectiveCwd = spawnResult.runtimeCwd ?? lastEffectiveCwd; + lastEffectiveCli = spawnResult.cli; + } promptTaskText = typeof spawnResult === 'string' ? effectiveOwner.interactive === false @@ -5482,6 +5505,7 @@ export class WorkflowRunner { step.name, lastEffectiveAgentDef, lastEffectiveCwd, + lastEffectiveCli, lastAttemptStartedAt, Date.now() ); @@ -5564,6 +5588,7 @@ export class WorkflowRunner { step.name, lastEffectiveAgentDef, lastEffectiveCwd, + lastEffectiveCli, lastAttemptStartedAt, Date.now() ); @@ -5601,6 +5626,7 @@ export class WorkflowRunner { step.name, lastEffectiveAgentDef, lastEffectiveCwd, + lastEffectiveCli, lastAttemptStartedAt, Date.now() ); @@ -5646,11 +5672,18 @@ export class WorkflowRunner { ); return null; } + if (!rawDiagnosticDef.cli) { + this.log( + `[${step.name}] Diagnostic agent "${diagnosticAgentName}" is persona-based and cannot run non-interactively` + ); + return null; + } - const diagnosticAgentDef: AgentDefinition = { + const diagnosticAgentDef = { ...WorkflowRunner.resolveAgentDef(rawDiagnosticDef), + cli: rawDiagnosticDef.cli, interactive: false, - }; + } as AgentDefinition; const verificationCommand = verification.type === 'custom' ? verification.value : `${verification.type}: ${verification.value}`; const diagnosticTimeout = verification.diagnosticTimeout ?? 60_000; @@ -5686,6 +5719,7 @@ export class WorkflowRunner { step.name, diagnosticAgentDef, diagnosticCwd, + diagnosticAgentDef.cli, startedAt, Date.now() ); @@ -5713,6 +5747,7 @@ export class WorkflowRunner { step.name, diagnosticAgentDef, diagnosticCwd, + diagnosticAgentDef.cli, startedAt, Date.now() ); @@ -7303,17 +7338,7 @@ export class WorkflowRunner { const personaResolution = agentDef.persona ? resolveWorkflowPersona(agentDef.persona, this.resolveExecutionCwd(step, agentDef)) : undefined; - if (personaResolution) { - agentDef = { - ...agentDef, - cli: personaResolution.cli as AgentCli, - constraints: { - ...(agentDef.constraints ?? {}), - model: personaResolution.model, - }, - }; - } - const agentCli = agentDef.cli; + const agentCli = personaResolution?.cli ?? agentDef.cli; if (!agentCli) { throw new Error(`Agent "${agentDef.name}" did not resolve to a launchable CLI`); } @@ -7413,6 +7438,7 @@ export class WorkflowRunner { let agentReleased = false; let completedWithoutSpawnError = false; let activePersona: ActiveWorkflowPersona | undefined; + const stepDeadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs; try { const agentCwd = this.resolveExecutionCwd(step, agentDef); @@ -7459,9 +7485,11 @@ export class WorkflowRunner { ); if (personaResolution) { + const remainingForReadiness = + stepDeadline === undefined ? 90_000 : Math.max(0, Math.min(stepDeadline - Date.now(), 90_000)); const ready = await this.waitForRuntimeAgentReady( agent, - Math.min(timeoutMs ?? 90_000, 90_000) + remainingForReadiness ); if (ready !== 'ready') { throw new Error( @@ -7595,7 +7623,7 @@ export class WorkflowRunner { agent, agentDef, step, - timeoutMs, + stepDeadline === undefined ? timeoutMs : Math.max(0, stepDeadline - Date.now()), preparedTask.promptTaskText, options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole) ); @@ -7688,7 +7716,11 @@ export class WorkflowRunner { if (preparedTask.taskTmpFile) { await unlink(preparedTask.taskTmpFile).catch(() => undefined); } - await activePersona?.dispose(); + await activePersona?.dispose().catch((error: unknown) => { + this.log( + `[${step.name}] Persona runtime cleanup failed: ${error instanceof Error ? error.message : String(error)}` + ); + }); } let output: string; @@ -7724,19 +7756,23 @@ export class WorkflowRunner { exitCode: agent?.exitCode, exitSignal: agent?.exitSignal, promptTaskText: preparedTask.promptTaskText, + ...(activePersona ? { runtimeCwd: activePersona.cwd } : {}), + cli: agentCli, }; } private async waitForRuntimeAgentReady( agent: WorkflowAgentHandle, timeoutMs: number - ): Promise<'ready' | 'exited' | 'timeout'> { + ): Promise<'ready' | 'exited' | 'timeout' | 'aborted'> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (this.readyRuntimeAgents.has(agent.name)) return 'ready'; if (agent.exitCode !== undefined || agent.exitSignal !== undefined) return 'exited'; + if (this.abortController?.signal.aborted) return 'aborted'; await sleepMs(25); } + if (this.abortController?.signal.aborted) return 'aborted'; return this.readyRuntimeAgents.has(agent.name) ? 'ready' : 'timeout'; } @@ -9938,14 +9974,15 @@ export class WorkflowRunner { stepName: string, agentDef: AgentDefinition | undefined, cwd: string | undefined, + cliOverride: AgentCli | undefined, startedAt: number | undefined, completedAt: number ): Promise { if (!agentDef || !cwd || !startedAt) return; try { - const cli = agentDef.cli ?? - (agentDef.persona ? (resolveWorkflowPersona(agentDef.persona, cwd).cli as AgentCli) : undefined); + const cli = cliOverride ?? agentDef.cli ?? + (agentDef.persona ? resolveWorkflowPersona(agentDef.persona, cwd).cli : undefined); if (!cli) return; const report = await collectCliSession({ cli, diff --git a/packages/core/src/schema.json b/packages/core/src/schema.json index bf56b05..6fe05dc 100644 --- a/packages/core/src/schema.json +++ b/packages/core/src/schema.json @@ -447,8 +447,32 @@ } }, "oneOf": [ - { "required": ["cli"] }, - { "required": ["persona"] } + { + "required": ["cli"], + "not": { "required": ["persona"] } + }, + { + "required": ["persona"], + "not": { + "anyOf": [ + { "required": ["cli"] }, + { "required": ["role"] }, + { "required": ["preset"] }, + { + "properties": { + "constraints": { "required": ["model"] } + }, + "required": ["constraints"] + }, + { + "properties": { + "interactive": { "enum": [false] } + }, + "required": ["interactive"] + } + ] + } + } ] }, "AgentCli": { @@ -459,7 +483,6 @@ "gemini", "aider", "goose", - "grok", "opencode", "droid", "cursor", diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index cee8312..129b489 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -44,13 +44,9 @@ export interface AgentCredentialConfig { provider?: string; } -/** Definition of an agent participating in a workflow. */ -export interface AgentDefinition { +/** Fields shared by raw-CLI and persona-backed workflow agents. */ +interface AgentDefinitionBase { name: string; - /** Raw harness launch. Mutually exclusive with `persona` in workflow YAML. */ - cli?: AgentCli; - /** AgentWorkforce persona id or JSON path. Mutually exclusive with `cli`. */ - persona?: string; role?: string; task?: string; channels?: string[]; @@ -91,13 +87,29 @@ export interface AgentDefinition { skills?: string; } +/** Definition of an agent participating in a workflow. */ +export type AgentDefinition = + | (AgentDefinitionBase & { + /** Raw harness launch. Mutually exclusive with `persona` in workflow YAML. */ + cli: AgentCli; + persona?: never; + }) + | (Omit & { + /** AgentWorkforce persona id or JSON path. Mutually exclusive with `cli`. */ + persona: string; + cli?: never; + role?: never; + preset?: never; + interactive?: true; + constraints?: Omit; + }); + export type AgentCli = | 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' - | 'grok' | 'opencode' | 'droid' | 'cursor' diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts index b8dafda..5fe464e 100644 --- a/packages/core/src/templates.ts +++ b/packages/core/src/templates.ts @@ -394,9 +394,21 @@ export class TemplateRegistry { } for (const agent of rawConfig.agents) { - if (!isRecord(agent) || typeof agent.name !== 'string' || typeof agent.cli !== 'string') { + if (!isRecord(agent) || typeof agent.name !== 'string') { throw new Error(`Template at ${source} contains an invalid agent definition`); } + const hasCli = typeof agent.cli === 'string' && agent.cli.trim().length > 0; + const hasPersona = typeof agent.persona === 'string' && agent.persona.trim().length > 0; + if (hasCli === hasPersona) { + throw new Error(`Template at ${source} contains an invalid agent definition`); + } + if (hasPersona && (agent.role !== undefined || agent.preset !== undefined || agent.interactive === false)) { + throw new Error(`Template at ${source} contains an invalid persona agent definition`); + } + const constraints = isRecord(agent.constraints) ? agent.constraints : undefined; + if (hasPersona && constraints?.model !== undefined) { + throw new Error(`Template at ${source} contains an invalid persona agent definition`); + } } if (rawConfig.workflows !== undefined) { From 955e0d34091e2c854c990179f2d84caa6621ca73 Mon Sep 17 00:00:00 2001 From: Miya Date: Sat, 8 Aug 2026 22:44:08 +0200 Subject: [PATCH 3/3] fix: reject malformed template launch fields --- .../src/__tests__/yaml-validation.test.ts | 21 +++++++++++++++++++ packages/core/src/templates.ts | 3 +++ 2 files changed, 24 insertions(+) diff --git a/packages/core/src/__tests__/yaml-validation.test.ts b/packages/core/src/__tests__/yaml-validation.test.ts index ddf1a9f..bfa2302 100644 --- a/packages/core/src/__tests__/yaml-validation.test.ts +++ b/packages/core/src/__tests__/yaml-validation.test.ts @@ -349,6 +349,27 @@ agents: expect(() => (registry as any).validateRelayConfig(parsed, 'test')).toThrow(/invalid agent/i); }); + for (const { name, launchFields } of [ + { name: 'numeric cli', launchFields: 'persona: nango-integrations\n cli: 42' }, + { name: 'boolean persona', launchFields: 'cli: relay\n persona: false' }, + { name: 'empty cli', launchFields: 'persona: nango-integrations\n cli: ""' }, + { name: 'blank persona', launchFields: 'cli: relay\n persona: " "' }, + ]) { + it(`should reject a malformed ${name} field`, () => { + const invalidYaml = ` +version: "1.0" +name: test +swarm: + pattern: fan-out +agents: + - name: test + ${launchFields} +`; + const parsed = parseYaml(invalidYaml); + expect(() => (registry as any).validateRelayConfig(parsed, 'test')).toThrow(/invalid agent/i); + }); + } + it('should reject non-existent template', async () => { await expect(registry.loadTemplate('non-existent-template')).rejects.toThrow(/not found/i); }); diff --git a/packages/core/src/templates.ts b/packages/core/src/templates.ts index 5fe464e..4776295 100644 --- a/packages/core/src/templates.ts +++ b/packages/core/src/templates.ts @@ -399,6 +399,9 @@ export class TemplateRegistry { } const hasCli = typeof agent.cli === 'string' && agent.cli.trim().length > 0; const hasPersona = typeof agent.persona === 'string' && agent.persona.trim().length > 0; + if ((agent.cli !== undefined && !hasCli) || (agent.persona !== undefined && !hasPersona)) { + throw new Error(`Template at ${source} contains an invalid agent definition`); + } if (hasCli === hasPersona) { throw new Error(`Template at ${source} contains an invalid agent definition`); }