From d2fbe67af0dccfb7fd9f6290902b62a55b2ecb33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 17:48:43 +0000 Subject: [PATCH 1/4] sdk: migrate persona spawn to @agentworkforce/persona-kit Replaces @agentworkforce/harness-kit + @agentworkforce/workload-router with @agentworkforce/persona-kit@^3, and rewrites AgentRelay.spawnPersona to run the full persona lifecycle (skills install, mount policy, CLAUDE.md / AGENTS.md sidecars, harness config files, persona inputs) via persona-kit's buildPersonaSpawnPlan + executePersonaSpawnPlan. All side effects are reversed when the spawned agent exits. The relay-side personas module now only owns the search-dir cascade and file discovery; parsing, plan construction, and execution belong to persona-kit so relay and the workforce CLI launch personas identically. Adds AgentRelay.getPersonaSpawnPlan(id) for dry-run inspection of the plan without filesystem writes or subprocess spawns. Breaking: the persona tier system is dropped, along with the legacy relay-side PersonaFile / PersonaTier / ResolvedPersona / PersonaSpawnSpec types and the buildPersonaSpawnSpec / materializePersonaConfigFiles / restorePersonaConfigFiles helpers. loadPersona now returns the canonical PersonaSpec; spawnPersona's `persona` override takes a PersonaSpec; the `tier` option is gone. Closes #832. --- CHANGELOG.md | 11 + package-lock.json | 333 ++++++++++++- packages/sdk/package.json | 3 +- packages/sdk/src/__tests__/personas.test.ts | 343 ++++++------- packages/sdk/src/personas.ts | 503 ++++++-------------- packages/sdk/src/relay.ts | 145 ++++-- 6 files changed, 732 insertions(+), 606 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ce1b195..f4d83250a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Broker and TypeScript SDK structured result contracts add the `submit_result` MCP tool, `agent.waitForResult()`, per-spawn `result.onResult`, and `relay.addListener('agentResult', ...)` for typed JSON worker outcomes. +- `@agent-relay/sdk` adds `AgentRelay.getPersonaSpawnPlan(id)` and a `getPersonaSpawnPlan` export for dry-run inspection of a persona's resolved harness argv, skill installs, mount policy, sidecars, and inputs. ### Changed - Release workflow changelog generation now writes concise Keep a Changelog sections and skips web-only, release-only, trajectory, PR-review, placeholder, and withdrawn-tag entries. +- `@agent-relay/sdk` `spawnPersona` now runs the full `@agentworkforce/persona-kit` lifecycle (skill installs, mount policy, `CLAUDE.md` / `AGENTS.md` sidecars, persona inputs) before launching the harness, and reverses every side effect when the agent exits. Previously it only translated the harness argv and silently dropped the rest of the schema. + +### Breaking Changes + +- `@agent-relay/sdk` swaps `@agentworkforce/harness-kit` + `@agentworkforce/workload-router` for `@agentworkforce/persona-kit@^3`. The persona tier system, the `tier` option on `spawnPersona`, the legacy relay-side `PersonaFile` / `PersonaTier` / `PersonaTierSpec` / `ResolvedPersona` / `PersonaSpawnSpec` / `MaterializedConfigFile` types, and the `buildPersonaSpawnSpec` / `materializePersonaConfigFiles` / `restorePersonaConfigFiles` helpers are removed. `loadPersona` now returns the canonical `PersonaSpec`, and `spawnPersona({ persona })` takes a `PersonaSpec` instead of a resolved persona. + +### Migration Guidance + +- Personas relying on `tiers.*` need to be flattened to a single top-level `harness` / `model` / `systemPrompt`. The shape that persona-kit (and the `agentworkforce` CLI) consumes is now the only supported shape. +- Callers that previously used `spawnPersona` to "just launch the harness" — without persona-kit's skill / mount / sidecar side effects — should use `AgentRelay.getPersonaSpawnPlan(id)` to inspect the plan and call `spawnPty` with the plan's `cli` + `args` themselves. ### Fixed diff --git a/package-lock.json b/package-lock.json index 9a59a245a..791e9d36b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -193,18 +193,26 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@agentworkforce/harness-kit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@agentworkforce/harness-kit/-/harness-kit-0.11.0.tgz", - "integrity": "sha512-CtW9P0pVm0j5R+kl7OaWMkPz7akYZqJNLmQ8k1m5Ony7NIfxJKuGiTBH9kcg+6vQ7fUtnfkoa34wt3y/pEh2QQ==", + "node_modules/@agentworkforce/persona-kit": { + "version": "3.0.19", + "resolved": "https://registry.npmjs.org/@agentworkforce/persona-kit/-/persona-kit-3.0.19.tgz", + "integrity": "sha512-HWyHeMdScfJSRnsfcGIzgVD9dbVlFYCJYXRxC43AhUhy+7CSqBwlgVRWFJHLXDL9AdJn9lAkhtX8+Jp8zt2bbA==", "dependencies": { - "@agentworkforce/workload-router": "0.11.0" + "@relayfile/local-mount": "^0.7.24" } }, - "node_modules/@agentworkforce/workload-router": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@agentworkforce/workload-router/-/workload-router-0.11.0.tgz", - "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==" + "node_modules/@agentworkforce/persona-kit/node_modules/@relayfile/local-mount": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/@relayfile/local-mount/-/local-mount-0.7.38.tgz", + "integrity": "sha512-eRQmKQwexfstqW21B3YCI4sKQOEHGL/lLqSsE2FRA8rl67qPomugWPpyG9gliYIMkGKYQr7CG6e9Mghx12T1nw==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.6", + "ignore": "^7.0.5" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -3721,6 +3729,301 @@ "win32" ] }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "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-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "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.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -9776,7 +10079,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9801,7 +10103,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -12152,6 +12453,12 @@ "node": "^10 || ^12 || >=14" } }, + "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", @@ -12678,7 +12985,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" @@ -16929,8 +17235,7 @@ "@agent-relay/github-primitive": "7.1.0", "@agent-relay/slack-primitive": "7.1.0", "@agent-relay/workflow-types": "7.1.0", - "@agentworkforce/harness-kit": "^0.11.0", - "@agentworkforce/workload-router": "^0.11.0", + "@agentworkforce/persona-kit": "^3.0.19", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 422be1ed0..a78e6d081 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -170,8 +170,7 @@ "@agent-relay/github-primitive": "7.1.0", "@agent-relay/slack-primitive": "7.1.0", "@agent-relay/workflow-types": "7.1.0", - "@agentworkforce/harness-kit": "^0.11.0", - "@agentworkforce/workload-router": "^0.11.0", + "@agentworkforce/persona-kit": "^3.0.19", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts index 6840e6500..0c92d4097 100644 --- a/packages/sdk/src/__tests__/personas.test.ts +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -1,24 +1,51 @@ /** - * Persona loader + translator tests. + * Persona loader + spawn-plan tests. + * + * Persona-kit owns the spawn-plan and execution surface; the tests here + * cover the relay-specific discovery cascade, the parsed PersonaSpec + * round-trip, and the AgentRelay.spawnPersona / getPersonaSpawnPlan + * methods. */ -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { test } from 'vitest'; +import { assert, test } from 'vitest'; import { - buildPersonaSpawnSpec, composePersonaTask, defaultPersonaSearchDirs, findPersona, + getPersonaSpawnPlan, listPersonas, loadPersona, - materializePersonaConfigFiles, - restorePersonaConfigFiles, + resolvePersona, } from '../personas.js'; import { AgentRelay } from '../relay.js'; +interface PersonaJsonOptions { + id: string; + intent?: string; + harness?: 'claude' | 'codex' | 'opencode'; + model?: string; + systemPrompt?: string; + description?: string; + extras?: Record; +} + +function personaJson(opts: PersonaJsonOptions): Record { + return { + id: opts.id, + intent: opts.intent ?? opts.id, + description: opts.description ?? `${opts.id} fixture`, + harness: opts.harness ?? 'claude', + model: opts.model ?? 'claude-opus-4-6', + systemPrompt: opts.systemPrompt ?? `You are ${opts.id}.`, + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, + ...(opts.extras ?? {}), + }; +} + function makeFixture(): { cwd: string; cleanup: () => void } { const root = mkdtempSync(join(tmpdir(), 'relay-personas-')); const dir = join(root, 'agentworkforce', 'personas'); @@ -26,43 +53,44 @@ function makeFixture(): { cwd: string; cleanup: () => void } { writeFileSync( join(dir, 'frontend.json'), - JSON.stringify({ - id: 'frontend', - description: 'frontend implementer', - tiers: { - best: { - harness: 'claude', - model: 'claude-opus-4-6', - systemPrompt: 'You are a senior frontend engineer.', + JSON.stringify( + personaJson({ + id: 'frontend', + intent: 'implement-frontend', + harness: 'claude', + model: 'claude-opus-4-6', + systemPrompt: 'You are a senior frontend engineer.', + extras: { + permissions: { allow: ['Bash(npm test)'], mode: 'default' }, }, - 'best-value': { - harness: 'codex', - model: 'openai-codex/gpt-5-codex', - systemPrompt: 'You are an efficient frontend engineer.', - }, - minimum: { - harness: 'opencode', - model: 'opencode/gpt-5-nano', - systemPrompt: 'You are a concise frontend engineer.', - }, - }, - permissions: { - allow: ['Bash(npm test)'], - mode: 'default', - }, - }), + }), + ), + ); + + writeFileSync( + join(dir, 'codex-reviewer.json'), + JSON.stringify( + personaJson({ + id: 'codex-reviewer', + intent: 'review', + harness: 'codex', + model: 'openai-codex/gpt-5-codex', + systemPrompt: 'You are an efficient code reviewer.', + }), + ), ); - // A second persona with extends to verify cascade lookup writeFileSync( - join(dir, 'frontend-strict.json'), - JSON.stringify({ - id: 'frontend-strict', - extends: 'frontend', - permissions: { - deny: ['Bash(rm -rf *)'], - }, - }), + join(dir, 'opencode-nano.json'), + JSON.stringify( + personaJson({ + id: 'opencode-nano', + intent: 'review', + harness: 'opencode', + model: 'opencode/gpt-5-nano', + systemPrompt: 'You are a concise reviewer.', + }), + ), ); return { @@ -84,7 +112,7 @@ test('listPersonas discovers JSON files under agentworkforce/personas', () => { try { const personas = listPersonas({ cwd: fix.cwd }); const ids = personas.map((p) => p.id).sort(); - assert.deepEqual(ids, ['frontend', 'frontend-strict']); + assert.deepEqual(ids, ['codex-reviewer', 'frontend', 'opencode-nano']); } finally { fix.cleanup(); } @@ -102,151 +130,150 @@ test('findPersona returns spec by id, regardless of filename', () => { } }); -test('loadPersona resolves the requested tier', () => { +test('loadPersona returns the parsed PersonaSpec verbatim', () => { const fix = makeFixture(); try { - const best = loadPersona('frontend', { cwd: fix.cwd }); - assert.equal(best.tier, 'best'); - assert.equal(best.harness, 'claude'); - assert.equal(best.model, 'claude-opus-4-6'); - assert.match(best.systemPrompt, /senior frontend engineer/); - - const value = loadPersona('frontend', { cwd: fix.cwd, tier: 'best-value' }); - assert.equal(value.harness, 'codex'); - assert.equal(value.model, 'openai-codex/gpt-5-codex'); - - const min = loadPersona('frontend', { cwd: fix.cwd, tier: 'minimum' }); - assert.equal(min.harness, 'opencode'); - assert.equal(min.model, 'opencode/gpt-5-nano'); + const spec = loadPersona('frontend', { cwd: fix.cwd }); + assert.equal(spec.id, 'frontend'); + assert.equal(spec.harness, 'claude'); + assert.equal(spec.model, 'claude-opus-4-6'); + assert.match(spec.systemPrompt, /senior frontend engineer/); + assert.deepEqual(spec.permissions?.allow, ['Bash(npm test)']); } finally { fix.cleanup(); } }); -test('loadPersona applies extends and merges permissions', () => { +test('loadPersona throws when persona is missing', () => { const fix = makeFixture(); try { - const strict = loadPersona('frontend-strict', { cwd: fix.cwd }); - assert.equal(strict.harness, 'claude'); - assert.deepEqual(strict.permissions?.allow, ['Bash(npm test)']); - assert.deepEqual(strict.permissions?.deny, ['Bash(rm -rf *)']); - assert.equal(strict.permissions?.mode, 'default'); + assert.throws(() => loadPersona('does-not-exist', { cwd: fix.cwd }), /not found/); } finally { fix.cleanup(); } }); -test('loadPersona throws when persona is missing', () => { +test('loadPersona surfaces persona-kit parse errors with file context', () => { const fix = makeFixture(); try { - assert.throws(() => loadPersona('does-not-exist', { cwd: fix.cwd }), /not found/); + const dir = join(fix.cwd, 'agentworkforce', 'personas'); + writeFileSync( + join(dir, 'bad.json'), + JSON.stringify({ + id: 'bad', + intent: 'review', + description: 'bad fixture', + harness: 'not-a-harness', + model: 'x', + systemPrompt: 'x', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, + }), + ); + assert.throws( + () => loadPersona('bad', { cwd: fix.cwd }), + /harness/, + ); } finally { fix.cleanup(); } }); -test('buildPersonaSpawnSpec for claude includes system prompt and MCP flags', () => { +test('resolvePersona projects PersonaSpec into a PersonaSelection-shaped ResolvedPersona', () => { const fix = makeFixture(); try { - const persona = loadPersona('frontend', { cwd: fix.cwd }); - const spec = buildPersonaSpawnSpec(persona); - assert.equal(spec.cli, 'claude'); - assert.equal(spec.model, 'claude-opus-4-6'); - assert.equal(spec.initialPrompt, null); - assert.deepEqual(spec.configFiles, []); - assert.ok(spec.args.includes('--append-system-prompt')); - assert.ok(spec.args.includes('--strict-mcp-config')); - const promptIdx = spec.args.indexOf('--append-system-prompt'); - assert.match(spec.args[promptIdx + 1] ?? '', /senior frontend engineer/); - assert.ok(spec.args.includes('--allowedTools')); + const spec = loadPersona('frontend', { cwd: fix.cwd }); + const resolved = resolvePersona(spec); + assert.equal(resolved.personaId, 'frontend'); + assert.equal(resolved.harness, 'claude'); + assert.equal(resolved.model, 'claude-opus-4-6'); + assert.equal(resolved.rationale, ''); + assert.deepEqual(resolved.permissions?.allow, ['Bash(npm test)']); } finally { fix.cleanup(); } }); -test('buildPersonaSpawnSpec for codex strips provider prefix and exposes initialPrompt', () => { +test('getPersonaSpawnPlan for claude includes system prompt and harness argv', () => { const fix = makeFixture(); try { - const persona = loadPersona('frontend', { cwd: fix.cwd, tier: 'best-value' }); - const spec = buildPersonaSpawnSpec(persona); - assert.equal(spec.cli, 'codex'); - // codex receives the stripped provider/model form via -m - assert.deepEqual(spec.args, ['-m', 'gpt-5-codex']); - assert.match(spec.initialPrompt ?? '', /efficient frontend engineer/); - - const taskWithPrompt = composePersonaTask(spec, 'Refactor the login page.'); - assert.match(taskWithPrompt ?? '', /efficient frontend engineer/); - assert.match(taskWithPrompt ?? '', /User task:\nRefactor the login page\./); + const plan = getPersonaSpawnPlan('frontend', { cwd: fix.cwd }); + assert.equal(plan.cli, 'claude'); + assert.equal(plan.persona.model, 'claude-opus-4-6'); + assert.ok(plan.args.includes('--append-system-prompt')); + const promptIdx = plan.args.indexOf('--append-system-prompt'); + assert.match(plan.args[promptIdx + 1] ?? '', /senior frontend engineer/); + assert.ok(plan.args.includes('--allowedTools')); } finally { fix.cleanup(); } }); -test('buildPersonaSpawnSpec for opencode emits an opencode.json config file', () => { +test('getPersonaSpawnPlan for codex exposes initialPrompt and composePersonaTask folds it in', () => { const fix = makeFixture(); try { - const persona = loadPersona('frontend', { cwd: fix.cwd, tier: 'minimum' }); - const spec = buildPersonaSpawnSpec(persona); - assert.equal(spec.cli, 'opencode'); - assert.deepEqual(spec.args, ['--agent', 'frontend']); - assert.equal(spec.configFiles.length, 1); - assert.equal(spec.configFiles[0]?.path, 'opencode.json'); - const parsed = JSON.parse(spec.configFiles[0]?.contents ?? '{}'); - assert.equal(parsed.agent.frontend.model, 'opencode/gpt-5-nano'); - assert.match(parsed.agent.frontend.prompt, /concise frontend engineer/); + const plan = getPersonaSpawnPlan('codex-reviewer', { cwd: fix.cwd }); + assert.equal(plan.cli, 'codex'); + assert.match(plan.initialPrompt ?? '', /efficient code reviewer/); + const taskWithPrompt = composePersonaTask(plan, 'Review the login PR.'); + assert.match(taskWithPrompt ?? '', /efficient code reviewer/); + assert.match(taskWithPrompt ?? '', /User task:\nReview the login PR\./); } finally { fix.cleanup(); } }); -test('materializePersonaConfigFiles writes and restores files', () => { +test('getPersonaSpawnPlan for opencode emits a config file with the persona prompt', () => { const fix = makeFixture(); try { - const target = join(fix.cwd, 'opencode.json'); - writeFileSync(target, '{"original":true}\n', 'utf8'); - - const writes = materializePersonaConfigFiles(fix.cwd, [ - { path: 'opencode.json', contents: '{"replaced":true}\n' }, - ]); - assert.equal(readFileSync(target, 'utf8'), '{"replaced":true}\n'); - assert.equal(writes[0]?.existed, true); + const plan = getPersonaSpawnPlan('opencode-nano', { cwd: fix.cwd }); + assert.equal(plan.cli, 'opencode'); + assert.ok(plan.configFiles.length > 0); + const opencodeConfig = plan.configFiles.find((f) => f.path === 'opencode.json'); + assert.ok(opencodeConfig, 'opencode.json config file should be emitted'); + const parsed = JSON.parse(opencodeConfig?.contents ?? '{}'); + assert.equal(parsed.agent['opencode-nano'].model, 'opencode/gpt-5-nano'); + assert.match(parsed.agent['opencode-nano'].prompt, /concise reviewer/); + } finally { + fix.cleanup(); + } +}); - restorePersonaConfigFiles(writes); - assert.equal(readFileSync(target, 'utf8'), '{"original":true}\n'); +test('getPersonaSpawnPlan plan is JSON-serializable round-trip', () => { + const fix = makeFixture(); + try { + const plan = getPersonaSpawnPlan('frontend', { cwd: fix.cwd }); + const round = JSON.parse(JSON.stringify(plan)); + assert.deepEqual(round, plan); } finally { fix.cleanup(); } }); -test('materializePersonaConfigFiles removes files that did not previously exist', () => { +test('AgentRelay.getPersonaSpawnPlan honors personaDirs from the constructor', () => { const fix = makeFixture(); try { - const target = join(fix.cwd, 'opencode.json'); - const writes = materializePersonaConfigFiles(fix.cwd, [ - { path: 'opencode.json', contents: '{"new":true}\n' }, - ]); - assert.equal(existsSync(target), true); - restorePersonaConfigFiles(writes); - assert.equal(existsSync(target), false); + const personaDir = join(fix.cwd, 'agentworkforce', 'personas'); + const relay = new AgentRelay({ personaDirs: [personaDir] }); + const plan = relay.getPersonaSpawnPlan('frontend'); + assert.equal(plan.cli, 'claude'); + assert.equal(plan.persona.personaId, 'frontend'); } finally { fix.cleanup(); } }); -test('AgentRelay personaDirs option supplies default search dirs to spawnPersona', async () => { +test('AgentRelay.spawnPersona honors constructor personaDirs and executes the plan', async () => { const fix = makeFixture(); try { const personaDir = join(fix.cwd, 'agentworkforce', 'personas'); const relay = new AgentRelay({ personaDirs: [personaDir] }); - let captured: { cli?: string; model?: string; args?: string[] } = {}; - // Stub out spawnPty so the test never touches the broker — we only care - // that the persona was discovered and translated using the constructor's - // personaDirs / personaTier defaults. + let captured: { cli?: string; cwd?: string; args?: string[]; model?: string } = {}; (relay as unknown as { spawnPty: (input: unknown) => Promise }).spawnPty = async ( input: unknown, ) => { - captured = input as { cli?: string; model?: string; args?: string[] }; + captured = input as typeof captured; return { name: (input as { name: string }).name, runtime: 'pty', @@ -263,13 +290,10 @@ test('AgentRelay personaDirs option supplies default search dirs to spawnPersona }; }; - await relay.spawnPersona('frontend', { - cwd: fix.cwd, // spawn cwd; persona lookup uses constructor defaults - tier: 'best-value', - }); + await relay.spawnPersona('codex-reviewer', { cwd: fix.cwd }); assert.equal(captured.cli, 'codex'); - assert.deepEqual(captured.args, ['-m', 'gpt-5-codex']); + assert.equal(captured.model, 'openai-codex/gpt-5-codex'); } finally { fix.cleanup(); } @@ -305,6 +329,7 @@ test('per-call searchDirs on spawnPersona overrides constructor defaults', async }; await relay.spawnPersona('frontend', { + cwd: otherFix.cwd, searchDirs: [join(otherFix.cwd, 'agentworkforce', 'personas')], }); @@ -314,69 +339,3 @@ test('per-call searchDirs on spawnPersona overrides constructor defaults', async fix.cleanup(); } }); - -test('materializePersonaConfigFiles rejects paths that escape cwd', () => { - const fix = makeFixture(); - try { - assert.throws( - () => materializePersonaConfigFiles(fix.cwd, [{ path: '../escape.json', contents: '{}' }]), - /escapes cwd/, - ); - } finally { - fix.cleanup(); - } -}); - -test('materializePersonaConfigFiles allows nested paths inside cwd', () => { - const fix = makeFixture(); - try { - const writes = materializePersonaConfigFiles(fix.cwd, [ - { path: 'sub/dir/opencode.json', contents: '{"nested":true}\n' }, - ]); - assert.equal(writes.length, 1); - assert.equal(readFileSync(writes[0]!.path, 'utf8'), '{"nested":true}\n'); - restorePersonaConfigFiles(writes); - assert.equal(existsSync(writes[0]!.path), false); - } finally { - fix.cleanup(); - } -}); - -test('parsePersonaFile rejects an invalid top-level harness at load time', () => { - const fix = makeFixture(); - try { - const dir = join(fix.cwd, 'agentworkforce', 'personas'); - writeFileSync( - join(dir, 'bad.json'), - JSON.stringify({ id: 'bad', harness: 'not-a-real-harness', model: 'x', systemPrompt: 'y' }), - ); - assert.throws( - () => loadPersona('bad', { cwd: fix.cwd }), - /persona\.harness must be one of/, - ); - } finally { - fix.cleanup(); - } -}); - -test('parsePersonaFile rejects an invalid harness inside a tier at load time', () => { - const fix = makeFixture(); - try { - const dir = join(fix.cwd, 'agentworkforce', 'personas'); - writeFileSync( - join(dir, 'bad-tier.json'), - JSON.stringify({ - id: 'bad-tier', - tiers: { - best: { harness: 'gpt-5', model: 'gpt-5', systemPrompt: 'x' }, - }, - }), - ); - assert.throws( - () => loadPersona('bad-tier', { cwd: fix.cwd }), - /persona\.tiers\.best\.harness must be one of/, - ); - } finally { - fix.cleanup(); - } -}); diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts index 4be3072f1..f2e556e02 100644 --- a/packages/sdk/src/personas.ts +++ b/packages/sdk/src/personas.ts @@ -1,96 +1,71 @@ /** - * Persona loading and translation. + * Persona loading and spawn-plan construction. * - * A persona is a JSON file that describes a pre-configured agent: which - * harness (CLI) to use, which model, what system prompt to inject, plus - * optional MCP servers and permission flags. Personas live in + * A persona is a JSON file that describes a pre-configured agent: harness, + * model, system prompt, skills, MCP servers, mount policy, sidecar + * markdown, inputs, and per-spawn env. Personas live in * `/agentworkforce/personas`, the AgentWorkforce home directory, or * any directory the caller passes explicitly. * - * Translation from a resolved persona to `{bin, args}` delegates to - * `@agentworkforce/harness-kit#buildInteractiveSpec`, so relay always - * produces the same launch args the AgentWorkforce CLI does. - * - * The schema mirrors the AgentWorkforce persona format - * (see https://github.com/AgentWorkforce/workforce). Skills installation, - * mount policy, sidecar markdown, input rendering, and routing profiles - * are deliberately not handled here — callers needing those should use - * the `agentworkforce` CLI directly. + * The persona schema is owned by `@agentworkforce/persona-kit` and is the + * same shape the `agentworkforce` CLI consumes. This module owns the + * relay-specific search-dir cascade and file discovery; it delegates + * everything else (parsing, spawn-plan construction, side-effect execution) + * to persona-kit so relay and the workforce CLI behave identically. */ -import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'; +import { isAbsolute, join, resolve as resolvePath } from 'node:path'; -import { - buildInteractiveSpec, - type BuildInteractiveSpecInput, - type InteractiveConfigFile, - type InteractiveSpec, -} from '@agentworkforce/harness-kit'; import { HARNESS_VALUES, - PERSONA_TIERS, + buildPersonaSpawnPlan, + executePersonaSpawnPlan, + isIntent, + parsePersonaSpec, + resolveSidecar, + sidecarSelectionFields, + type ExecuteOptions, + type ExecutionHandle, type Harness, type McpServerSpec, + type PersonaInputSpec, + type PersonaMount, type PersonaPermissions, - type PersonaTier, -} from '@agentworkforce/workload-router'; - -// ── Re-exports for callers ───────────────────────────────────────────────── - -export type { Harness, McpServerSpec, PersonaPermissions, PersonaTier }; -export { HARNESS_VALUES, PERSONA_TIERS }; + type PersonaSkill, + type PersonaSpawnPlan, + type PersonaSpec, + type PlanOptions, + type ResolvedPersona, + type SkillMaterializationPlan, +} from '@agentworkforce/persona-kit'; -// ── On-disk persona schema (permissive, like workforce's LocalPersonaOverride) ── - -export interface PersonaTierSpec { - harness?: Harness; - model?: string; - systemPrompt?: string; - /** Free-form harness settings (reasoning level, timeout) — not consumed by spawnPty today. */ - harnessSettings?: Record; -} - -/** Raw persona file shape. */ -export interface PersonaFile { - id: string; - intent?: string; - description?: string; - tags?: string[]; - /** Used for every tier when set without `tiers`. Ignored when `tiers` is set. */ - systemPrompt?: string; - /** Top-level harness/model — used when there are no tiers. */ - harness?: Harness; - model?: string; - permissions?: PersonaPermissions; - mcpServers?: Record; - /** Per-tier overrides. A tier set here takes precedence over the top-level fields. */ - tiers?: Partial>; - /** Inherits from another persona id (looked up in the same search dirs). One level deep. */ - extends?: string; -} - -/** A persona file located on disk. */ -export interface DiscoveredPersona { - id: string; - path: string; - spec: PersonaFile; -} +// ── Re-exports for SDK consumers ─────────────────────────────────────────── -/** A persona resolved against a tier — ready for {@link buildPersonaSpawnSpec}. */ -export interface ResolvedPersona { - id: string; - /** Absolute path to the JSON file the spec came from. */ - source: string; - tier: PersonaTier; - harness: Harness; - model: string; - systemPrompt: string; - description?: string; - permissions?: PersonaPermissions; - mcpServers?: Record; -} +export { + HARNESS_VALUES, + buildPersonaSpawnPlan, + executePersonaSpawnPlan, +}; + +export type { + ExecuteOptions, + ExecutionHandle, + Harness, + McpServerSpec, + PersonaInputSpec, + PersonaMount, + PersonaPermissions, + PersonaSkill, + PersonaSpawnPlan, + PersonaSpec, + PlanOptions, + ResolvedPersona, + SkillMaterializationPlan, +}; + +// ── Discovery types ──────────────────────────────────────────────────────── export interface PersonaLoadOptions { cwd?: string; @@ -98,32 +73,13 @@ export interface PersonaLoadOptions { searchDirs?: string[]; /** Extra dirs appended after the default cascade. */ extraDirs?: string[]; - /** Tier to resolve. Defaults to 'best'. */ - tier?: PersonaTier; } -/** - * The shape `AgentRelay.spawnPersona` needs to drive `spawnPty`. Built by - * {@link buildPersonaSpawnSpec} from a {@link ResolvedPersona}. - */ -export interface PersonaSpawnSpec { - /** CLI to launch (matches relay's AgentCli union: 'claude' | 'codex' | 'opencode'). */ - cli: string; - model: string; - args: string[]; - /** - * If non-null, append this as the final positional arg to the CLI invocation. - * Codex uses this to carry the system prompt; claude / opencode return null. - */ - initialPrompt: string | null; - /** - * Files the caller must materialize (relative to spawn cwd) before launching - * the agent. Used by opencode to drop an `opencode.json` carrying the - * persona's agent definition. Empty for claude / codex. - */ - configFiles: InteractiveConfigFile[]; - /** Non-fatal warnings from the harness-kit translation step. */ - warnings: string[]; +/** A persona file located on disk. */ +export interface DiscoveredPersona { + id: string; + path: string; + spec: PersonaSpec; } // ── Default search dirs ──────────────────────────────────────────────────── @@ -184,6 +140,30 @@ function dedupe(items: T[]): T[] { return out; } +// ── Parsing ──────────────────────────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parsePersonaJson(raw: unknown, source: string): PersonaSpec { + if (!isPlainObject(raw)) { + throw new Error(`${source}: persona must be a JSON object`); + } + const intent = raw.intent; + if (typeof intent !== 'string' || !intent.trim()) { + throw new Error(`${source}: persona.intent must be a non-empty string`); + } + if (!isIntent(intent)) { + throw new Error(`${source}: persona.intent "${intent}" is not a known PersonaIntent`); + } + // persona-kit's parser cross-checks the file's declared intent against an + // "expected" intent; relay loads personas by id so it has no expected intent + // — feed the file's own intent back in to make the mismatch check a no-op + // while keeping the rest of the schema validation. + return parsePersonaSpec(raw, intent); +} + // ── Discovery ────────────────────────────────────────────────────────────── /** @@ -205,11 +185,11 @@ export function listPersonas(options: PersonaLoadOptions = {}): DiscoveredPerson for (const file of entries) { if (!file.endsWith('.json')) continue; const path = join(dir, file); - let spec: PersonaFile; + let spec: PersonaSpec; try { // Single read avoids a TOCTOU between stat and readFileSync — if the // entry is a directory, vanished, or unreadable we skip it. - spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path); + spec = parsePersonaJson(JSON.parse(readFileSync(path, 'utf8')), path); } catch { continue; } @@ -235,17 +215,12 @@ export function findPersona( const candidate = join(dir, `${id}.json`); let candidateBytes: string | undefined; try { - // Single read avoids a stat/read TOCTOU. ENOENT (file missing) falls - // through to a directory scan for personas with mismatched filenames; - // any other read failure or parse failure on a convention-named file - // surfaces directly so a typo in the JSON isn't silently treated as - // "persona not found". candidateBytes = readFileSync(candidate, 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } if (candidateBytes !== undefined) { - const spec = parsePersonaFile(JSON.parse(candidateBytes), candidate); + const spec = parsePersonaJson(JSON.parse(candidateBytes), candidate); if (spec.id === id) return { id, path: candidate, spec }; } let entries: string[]; @@ -258,7 +233,7 @@ export function findPersona( if (!file.endsWith('.json')) continue; const path = join(dir, file); try { - const spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path); + const spec = parsePersonaJson(JSON.parse(readFileSync(path, 'utf8')), path); if (spec.id === id) return { id, path, spec }; } catch { continue; @@ -268,14 +243,13 @@ export function findPersona( return undefined; } -// ── Resolution ───────────────────────────────────────────────────────────── - /** - * Load and resolve a persona by id. Searches the cascade, applies the - * chosen tier, and resolves a single level of `extends` against the same - * cascade. Throws if the persona is missing required fields for the tier. + * Load a persona by id from the search-dir cascade. Returns the parsed + * {@link PersonaSpec} verbatim — callers wanting a spawn plan should pass + * the result to {@link resolvePersona} + {@link buildPersonaSpawnPlan}, + * or use {@link getPersonaSpawnPlan} as a one-shot. */ -export function loadPersona(id: string, options: PersonaLoadOptions = {}): ResolvedPersona { +export function loadPersona(id: string, options: PersonaLoadOptions = {}): PersonaSpec { const discovered = findPersona(id, options); if (!discovered) { const dirs = effectiveSearchDirs(options); @@ -284,265 +258,74 @@ export function loadPersona(id: string, options: PersonaLoadOptions = {}): Resol 'Set searchDirs / extraDirs to include the directory containing the persona file.', ); } - - const tier: PersonaTier = options.tier ?? 'best'; - let spec = discovered.spec; - - if (spec.extends) { - const base = findPersona(spec.extends, options); - if (!base) { - throw new Error( - `Persona "${id}" extends "${spec.extends}" but the base could not be found in the search cascade.`, - ); - } - spec = mergeSpecs(base.spec, spec); - } - - const tierSpec = spec.tiers?.[tier]; - const harness = (tierSpec?.harness ?? spec.harness) as Harness | undefined; - const model = tierSpec?.model ?? spec.model; - const systemPrompt = tierSpec?.systemPrompt ?? spec.systemPrompt; - - if (!harness) { - throw new Error( - `Persona "${id}" tier "${tier}" has no harness; set tiers.${tier}.harness or top-level harness.`, - ); - } - if (!HARNESS_VALUES.includes(harness)) { - throw new Error( - `Persona "${id}" tier "${tier}" uses unsupported harness "${String(harness)}". ` + - `Supported: ${HARNESS_VALUES.join(', ')}.`, - ); - } - if (!model) { - throw new Error( - `Persona "${id}" tier "${tier}" has no model; set tiers.${tier}.model or top-level model.`, - ); - } - if (!systemPrompt) { - throw new Error( - `Persona "${id}" tier "${tier}" has no systemPrompt; set tiers.${tier}.systemPrompt or top-level systemPrompt.`, - ); - } - - return { - id: spec.id, - source: discovered.path, - tier, - harness, - model, - systemPrompt, - description: spec.description, - permissions: spec.permissions, - mcpServers: spec.mcpServers, - }; + return discovered.spec; } -// ── Merge (extends) ──────────────────────────────────────────────────────── - -function mergeSpecs(base: PersonaFile, override: PersonaFile): PersonaFile { - const tiers: PersonaFile['tiers'] = {}; - for (const tier of PERSONA_TIERS) { - const baseTier = base.tiers?.[tier]; - const overrideTier = override.tiers?.[tier]; - if (overrideTier || baseTier) { - tiers[tier] = { ...(baseTier ?? {}), ...(overrideTier ?? {}) }; - } - } - - return { - id: override.id, - intent: override.intent ?? base.intent, - description: override.description ?? base.description, - tags: override.tags ?? base.tags, - systemPrompt: override.systemPrompt ?? base.systemPrompt, - harness: override.harness ?? base.harness, - model: override.model ?? base.model, - permissions: mergePermissions(base.permissions, override.permissions), - mcpServers: { ...(base.mcpServers ?? {}), ...(override.mcpServers ?? {}) }, - tiers: Object.keys(tiers).length > 0 ? tiers : undefined, - }; -} +// ── Resolution ───────────────────────────────────────────────────────────── -function mergePermissions( - base: PersonaPermissions | undefined, - override: PersonaPermissions | undefined, -): PersonaPermissions | undefined { - if (!base && !override) return undefined; - const allow = dedupe([...(base?.allow ?? []), ...(override?.allow ?? [])]); - const deny = dedupe([...(base?.deny ?? []), ...(override?.deny ?? [])]); +/** + * Project a {@link PersonaSpec} (the on-disk form) into a + * {@link ResolvedPersona} (persona-kit's spawn-input form). Used as glue + * between {@link loadPersona} and {@link buildPersonaSpawnPlan}. + * + * Relay has no routing/selection layer, so the `rationale` field is left + * empty. + */ +export function resolvePersona(spec: PersonaSpec): ResolvedPersona { + const sidecar = resolveSidecar(spec); return { - ...(allow.length > 0 ? { allow } : {}), - ...(deny.length > 0 ? { deny } : {}), - ...(override?.mode ?? base?.mode ? { mode: override?.mode ?? base?.mode } : {}), + personaId: spec.id, + harness: spec.harness, + model: spec.model, + systemPrompt: spec.systemPrompt, + harnessSettings: spec.harnessSettings, + skills: spec.skills, + rationale: '', + ...(spec.inputs ? { inputs: spec.inputs } : {}), + ...(spec.env ? { env: spec.env } : {}), + ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), + ...(spec.permissions ? { permissions: spec.permissions } : {}), + ...(spec.mount ? { mount: spec.mount } : {}), + ...sidecarSelectionFields(sidecar), }; } -// ── Validation ───────────────────────────────────────────────────────────── - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function parsePersonaFile(value: unknown, source: string): PersonaFile { - if (!isPlainObject(value)) { - throw new Error(`${source}: persona must be a JSON object`); - } - if (typeof value.id !== 'string' || !value.id.trim()) { - throw new Error(`${source}: persona.id must be a non-empty string`); - } - // Validate harness values up front so a typo in the file fails at load time - // rather than at spawn — the runtime check in loadPersona stays as a - // defense-in-depth guard for callers that bypass parsing. - const topHarness = (value as { harness?: unknown }).harness; - if (topHarness !== undefined && !isValidHarness(topHarness)) { - throw new Error( - `${source}: persona.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(topHarness)})`, - ); - } - const tiers = (value as { tiers?: unknown }).tiers; - if (tiers !== undefined) { - if (!isPlainObject(tiers)) { - throw new Error(`${source}: persona.tiers must be an object if provided`); - } - for (const [tierName, tierSpec] of Object.entries(tiers)) { - if (!PERSONA_TIERS.includes(tierName as PersonaTier)) continue; // unknown tier names are ignored - if (!isPlainObject(tierSpec)) continue; - const harness = (tierSpec as { harness?: unknown }).harness; - if (harness !== undefined && !isValidHarness(harness)) { - throw new Error( - `${source}: persona.tiers.${tierName}.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(harness)})`, - ); - } - } - } - return value as unknown as PersonaFile; -} - -function isValidHarness(value: unknown): value is Harness { - return typeof value === 'string' && (HARNESS_VALUES as readonly string[]).includes(value); -} - -// ── Translation: persona → spawn args ────────────────────────────────────── +export interface PersonaSpawnPlanOptions extends PersonaLoadOptions, PlanOptions {} /** - * Translate a resolved persona into the bin/args spawnPty needs. Delegates - * to {@link buildInteractiveSpec} from `@agentworkforce/harness-kit` so - * relay produces the same launch shape the AgentWorkforce CLI does. + * One-shot helper: load a persona by id and build its spawn plan. The plan + * describes everything the persona would do at spawn (skills installs, + * mount policy, sidecar writes, harness argv, env) without executing any + * of it. Useful for authoring tools, validators, and dry-runs. */ -export function buildPersonaSpawnSpec(persona: ResolvedPersona): PersonaSpawnSpec { - const input: BuildInteractiveSpecInput = { - harness: persona.harness, - personaId: persona.id, - model: persona.model, - systemPrompt: persona.systemPrompt, - ...(persona.mcpServers ? { mcpServers: persona.mcpServers } : {}), - ...(persona.permissions ? { permissions: persona.permissions } : {}), - }; - const spec: InteractiveSpec = buildInteractiveSpec(input); - return { - cli: spec.bin, - model: persona.model, - args: [...spec.args], - initialPrompt: spec.initialPrompt, - configFiles: [...spec.configFiles], - warnings: [...spec.warnings], - }; +export function getPersonaSpawnPlan( + personaId: string, + options: PersonaSpawnPlanOptions = {}, +): PersonaSpawnPlan { + const spec = loadPersona(personaId, options); + const resolved = resolvePersona(spec); + const planOptions: PlanOptions = {}; + if (options.installRoot !== undefined) planOptions.installRoot = options.installRoot; + if (options.envOverrides !== undefined) planOptions.envOverrides = options.envOverrides; + if (options.inputValues !== undefined) planOptions.inputValues = options.inputValues; + if (options.processEnv !== undefined) planOptions.processEnv = options.processEnv; + if (options.includeProcessEnv !== undefined) planOptions.includeProcessEnv = options.includeProcessEnv; + return buildPersonaSpawnPlan(resolved, planOptions); } +// ── Codex initial-prompt composition ─────────────────────────────────────── + /** - * Codex has no system-prompt flag, so the persona's instructions must ride - * on the task. Combines them in the same shape the agentworkforce - * harness-kit uses for non-interactive codex runs. + * Codex has no system-prompt flag, so the persona's instructions ride on + * the task. {@link buildPersonaSpawnPlan} exposes the persona's resolved + * prompt as `plan.initialPrompt` for that case; everything else returns + * `undefined` and the user task passes through unchanged. */ export function composePersonaTask( - spec: Pick, + plan: Pick, userTask: string | undefined, ): string | undefined { - if (!spec.initialPrompt) return userTask; - if (!userTask) return spec.initialPrompt; - return `${spec.initialPrompt}\n\nUser task:\n${userTask}`; -} - -// ── Config-file materialization helpers ──────────────────────────────────── - -/** Tracks a file we wrote, so the caller can restore the prior contents. */ -export interface MaterializedConfigFile { - /** Absolute path that was written. */ - path: string; - /** Whether a file existed at this path before we wrote. */ - existed: boolean; - /** Prior contents (only set when existed is true). */ - previous?: string; -} - -/** - * Write each persona config file into `cwd`. Refuses absolute paths or - * paths that escape `cwd`. Returns handles the caller can pass to - * {@link restorePersonaConfigFiles}. - */ -export function materializePersonaConfigFiles( - cwd: string, - files: readonly InteractiveConfigFile[], -): MaterializedConfigFile[] { - const out: MaterializedConfigFile[] = []; - const cwdAbs = resolvePath(cwd); - for (const file of files) { - if (!file.path) throw new Error('persona config file path must be non-empty'); - if (isAbsolute(file.path)) { - throw new Error(`persona config file path must be relative: ${file.path}`); - } - const target = resolvePath(cwd, file.path); - // Use path.relative for separator-agnostic containment so Windows paths - // (`C:\proj\opencode.json`) aren't falsely rejected by a hardcoded '/' check. - const rel = relative(cwdAbs, target); - if (rel.startsWith('..') || (isAbsolute(rel) && rel !== '')) { - throw new Error(`persona config file path escapes cwd: ${file.path}`); - } - if (rel.split(sep).some((segment) => segment === '..')) { - throw new Error(`persona config file path escapes cwd: ${file.path}`); - } - - // Single read with ENOENT detection avoids a TOCTOU between `existsSync` - // and `readFileSync`. Any other read error (permissions, EISDIR) bubbles up - // — the caller can decide whether to retry or surface to the user. - let existed = true; - let previous: string | undefined; - try { - previous = readFileSync(target, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - existed = false; - } else { - throw err; - } - } - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, file.contents, 'utf8'); - out.push({ path: target, existed, ...(previous !== undefined ? { previous } : {}) }); - } - return out; -} - -/** - * Restore the original state of files written by - * {@link materializePersonaConfigFiles}. Files that did not exist before - * are removed; files that did exist are written back to their prior - * contents. Errors are swallowed — restore is best-effort cleanup. - */ -export function restorePersonaConfigFiles(writes: readonly MaterializedConfigFile[]): void { - for (const write of [...writes].reverse()) { - try { - if (write.existed) { - writeFileSync(write.path, write.previous ?? '', 'utf8'); - } else { - rmSync(write.path, { force: true }); - } - } catch (err) { - // Best-effort: a failed restore shouldn't break the spawn lifecycle, but - // it can leave a stale opencode.json behind, so surface the failure. - const msg = (err as Error)?.message ?? String(err); - console.warn(`[personas] failed to restore ${write.path}: ${msg}`); - } - } + if (!plan.initialPrompt) return userTask; + if (!userTask) return plan.initialPrompt; + return `${plan.initialPrompt}\n\nUser task:\n${userTask}`; } diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index f9fbf30b1..98266ce25 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -35,14 +35,17 @@ import { AgentRelayClient, type AgentRelayBrokerInitArgs, type AgentRelaySpawnOp import { EventBus } from './event-bus.js'; import type { AgentRelayEvents, BeforeAgentSpawnHandler } from './lifecycle-hooks.js'; import { - buildPersonaSpawnSpec, + buildPersonaSpawnPlan, composePersonaTask, + executePersonaSpawnPlan, + getPersonaSpawnPlan as getPersonaSpawnPlanImpl, loadPersona, - materializePersonaConfigFiles, - restorePersonaConfigFiles, + resolvePersona, + type ExecuteOptions, type PersonaLoadOptions, - type PersonaTier, - type ResolvedPersona, + type PersonaSpawnPlan, + type PersonaSpawnPlanOptions, + type PersonaSpec, } from './personas.js'; import { AgentRelayProtocolError } from './transport.js'; import type { JsonSchema, SendMessageInput, SpawnPtyInput } from './types.js'; @@ -310,8 +313,6 @@ export interface SpawnPersonaOptions extends SpawnOption name?: string; /** Initial task / user prompt for the agent. */ task?: string; - /** Persona tier to resolve. Defaults to 'best'. */ - tier?: PersonaTier; /** * Override the persona search-dir cascade. When set, the default * directories (cwd/agentworkforce/personas, ~/.agentworkforce/...) are @@ -327,10 +328,27 @@ export interface SpawnPersonaOptions extends SpawnOption */ personaCwd?: string; /** - * Override the resolved persona before translation. Useful for callers - * that want to load+adjust+spawn in one step (e.g. tweak permissions). + * Override the parsed persona before spawn-plan construction. Useful for + * callers that want to load+adjust+spawn in one step (e.g. tweak + * permissions or skills). */ - persona?: ResolvedPersona; + persona?: PersonaSpec; + /** + * Stage skills under this absolute directory instead of the repo's + * `.claude/skills/`. Claude harness only — persona-kit throws otherwise. + */ + skillsInstallRoot?: string; + /** Caller-supplied values for persona inputs (highest precedence). */ + inputValues?: Record; + /** Extra env bindings to merge into `plan.env` (persona env wins on conflict). */ + envOverrides?: Record; + /** + * Whether the executor should remove `.claude/skills/` etc. on dispose. + * Defaults to persona-kit's default (true). + */ + cleanupSkillsOnDispose?: boolean; + /** Mount-specific options forwarded to persona-kit's executor. */ + mount?: ExecuteOptions['mount']; } type AgentOutputPayload = { stream: string; chunk: string }; @@ -884,17 +902,20 @@ export class AgentRelay { * Looks up the persona JSON in the search-dir cascade * (`/agentworkforce/personas`, `/.agentworkforce/workforce/personas`, * `~/.agentworkforce/workforce/personas`, plus `AGENT_WORKFORCE_HOME`), - * resolves the requested tier, and translates it to spawnPty args via - * `@agentworkforce/harness-kit#buildInteractiveSpec`. + * then runs the full `@agentworkforce/persona-kit` spawn lifecycle: + * installs the persona's skills, applies its mount policy, writes its + * `CLAUDE.md` / `AGENTS.md` sidecars and any harness config files, and + * resolves its declared inputs. The agent is then launched with the + * harness argv and prompt the persona-kit plan produced. * - * For opencode, an `opencode.json` is materialized in the spawn cwd and - * automatically restored when the agent exits. For codex, the persona's - * systemPrompt is folded into the initial task (codex has no - * system-prompt flag). Translation warnings are surfaced via console.warn. + * When the spawned agent exits, every side effect is reversed in LIFO + * order (skills uninstalled, sidecars restored, mount unmounted, etc.). + * A failure during the plan execution aborts before `spawnPty` runs and + * leaves no partial state. * * @param personaId — id of the persona to load - * @param options — overrides for tier, search dirs, name, task, and the - * underlying spawn options + * @param options — overrides for search dirs, name, task, inputs, and + * the underlying spawn options */ async spawnPersona( personaId: string, @@ -906,34 +927,56 @@ export class AgentRelay { cwd: personaCwd, ...(searchDirs ? { searchDirs } : {}), ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}), - ...(options.tier ? { tier: options.tier } : {}), }; - const persona = options.persona ?? loadPersona(personaId, loadOpts); - const spec = buildPersonaSpawnSpec(persona); - - for (const warning of spec.warnings) { - console.warn(`[AgentRelay] ${warning}`); - } + const spec = options.persona ?? loadPersona(personaId, loadOpts); + const resolved = resolvePersona(spec); + const planOptions = { + ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), + ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), + ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), + }; + const plan = buildPersonaSpawnPlan(resolved, planOptions); const spawnCwd = options.cwd ?? process.cwd(); - const writes = - spec.configFiles.length > 0 ? materializePersonaConfigFiles(spawnCwd, spec.configFiles) : []; + const executeOptions: ExecuteOptions = { + cwd: spawnCwd, + ...(options.cleanupSkillsOnDispose !== undefined + ? { cleanupSkillsOnDispose: options.cleanupSkillsOnDispose } + : {}), + ...(options.mount ? { mount: options.mount } : {}), + }; + const handle = await executePersonaSpawnPlan(plan, executeOptions); + + // Persona-kit's plan.env carries persona-author env + resolved inputs. + // The broker spawnPty API does not yet accept a per-spawn env; until + // that lands, surface any env beyond the harness's defaults so callers + // know they're being dropped. + const droppedEnv = Object.keys(plan.env ?? {}).filter( + (key) => process.env[key] !== plan.env[key], + ); + if (droppedEnv.length > 0) { + console.warn( + `[AgentRelay] persona "${spec.id}" declares env vars not forwardable through ` + + `the broker today: ${droppedEnv.join(', ')}. Set them in the spawning ` + + 'process env or pass them via options.envOverrides + set them yourself.', + ); + } const baseArgs = options.args ?? []; - const mergedArgs = [...spec.args, ...baseArgs]; - const task = composePersonaTask(spec, options.task); - const spawnName = options.name ?? persona.id; + const mergedArgs = [...plan.args, ...baseArgs]; + const task = composePersonaTask(plan, options.task); + const spawnName = options.name ?? spec.id; let agent: Agent; try { agent = await this.spawnPty({ name: spawnName, - cli: spec.cli, + cli: plan.cli, args: mergedArgs, ...(task !== undefined ? { task } : {}), channels: options.channels, - model: spec.model, - cwd: spawnCwd, + model: plan.persona.model, + cwd: handle.cwd, team: options.team, agentToken: options.agentToken, shadowOf: options.shadowOf, @@ -947,19 +990,45 @@ export class AgentRelay { onError: options.onError, }); } catch (err) { - restorePersonaConfigFiles(writes); + await handle.dispose(); throw err; } - if (writes.length > 0) { - void agent.waitForExit().finally(() => { - restorePersonaConfigFiles(writes); + void agent.waitForExit().finally(() => { + handle.dispose().catch((e: unknown) => { + const msg = (e as Error)?.message ?? String(e); + console.warn(`[AgentRelay] persona "${spec.id}" dispose failed: ${msg}`); }); - } + }); return agent; } + /** + * Build a {@link PersonaSpawnPlan} for the persona without executing it. + * Useful for authoring tools, validators, and dry-runs that want to + * inspect the persona's skill installs, mount policy, sidecars, and + * harness argv before committing to a spawn. + * + * Performs no filesystem writes and spawns no subprocesses. + */ + getPersonaSpawnPlan( + personaId: string, + options: SpawnPersonaOptions = {}, + ): PersonaSpawnPlan { + const personaCwd = options.personaCwd ?? options.cwd ?? process.cwd(); + const searchDirs = options.searchDirs ?? this.defaultPersonaDirs; + const callOptions: PersonaSpawnPlanOptions = { + cwd: personaCwd, + ...(searchDirs ? { searchDirs } : {}), + ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}), + ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), + ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), + ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), + }; + return getPersonaSpawnPlanImpl(personaId, callOptions); + } + // ── Human source ──────────────────────────────────────────────────────── human(opts: { name: string }): HumanHandle { From 30da4c05f49028f1185f66ac39eefa7d122d873e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 19:03:21 +0000 Subject: [PATCH 2/4] sdk: address persona-kit migration review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes flagged by codex, CodeRabbit, and cubic on PR #973: - spawnPersona: wrap handle.dispose() in its own try/catch on the spawnPty failure path so a dispose error never masks the original spawn error (P2, three reviewers). - getPersonaSpawnPlan: honor options.persona so dry-run plans match what spawnPersona would execute for the same options (P2, three reviewers). - spawnPersona: compute dropped-env warning against the broker's effective env (this.clientOptions.env ?? process.env) instead of always process.env (P3, two reviewers). - findPersona: tolerate a malformed shadow file at the conventional .json path so it doesn't block a valid persona lower in the cascade — matches the directory-scan path's existing behavior. Adds tests for getPersonaSpawnPlan({ persona }) and the findPersona shadow-file cascade fallthrough; updates the parse-error test to assert the new cascade semantics. --- packages/sdk/src/__tests__/personas.test.ts | 42 ++++++++++++++++++++- packages/sdk/src/personas.ts | 10 ++++- packages/sdk/src/relay.ts | 34 +++++++++++++---- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts index 0c92d4097..d9059509e 100644 --- a/packages/sdk/src/__tests__/personas.test.ts +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -153,7 +153,12 @@ test('loadPersona throws when persona is missing', () => { } }); -test('loadPersona surfaces persona-kit parse errors with file context', () => { +test('loadPersona reports "not found" when no valid persona with that id exists', () => { + // A malformed file at the conventional name no longer blocks the cascade — + // it is treated the same as a malformed sibling file: skipped during the + // search, and "not found" is reported if no valid alternative exists. This + // is the cascade behavior that lets a higher-priority shadow file with bad + // JSON not break a valid lower-priority persona of the same id. const fix = makeFixture(); try { const dir = join(fix.cwd, 'agentworkforce', 'personas'); @@ -172,7 +177,7 @@ test('loadPersona surfaces persona-kit parse errors with file context', () => { ); assert.throws( () => loadPersona('bad', { cwd: fix.cwd }), - /harness/, + /not found/, ); } finally { fix.cleanup(); @@ -299,6 +304,39 @@ test('AgentRelay.spawnPersona honors constructor personaDirs and executes the pl } }); +test('AgentRelay.getPersonaSpawnPlan honors options.persona, bypassing the search cascade', () => { + const fix = makeFixture(); + try { + const relay = new AgentRelay({ personaDirs: ['/nonexistent'] }); + const spec = loadPersona('frontend', { cwd: fix.cwd }); + const plan = relay.getPersonaSpawnPlan('frontend', { persona: spec }); + assert.equal(plan.cli, 'claude'); + assert.equal(plan.persona.personaId, 'frontend'); + } finally { + fix.cleanup(); + } +}); + +test('findPersona skips a malformed shadow file at the conventional path', () => { + const fix = makeFixture(); + const otherFix = makeFixture(); + try { + const shadowDir = join(otherFix.cwd, 'agentworkforce', 'personas'); + // Higher-priority shadow file with the conventional name but bad JSON. + writeFileSync(join(shadowDir, 'frontend.json'), '{ not valid json'); + const found = findPersona('frontend', { + cwd: fix.cwd, + searchDirs: [shadowDir, join(fix.cwd, 'agentworkforce', 'personas')], + }); + assert.ok(found, 'should fall through to the valid persona in the lower-priority dir'); + assert.match(found?.path ?? '', /frontend\.json$/); + assert.notMatch(found?.path ?? '', new RegExp(shadowDir)); + } finally { + otherFix.cleanup(); + fix.cleanup(); + } +}); + test('per-call searchDirs on spawnPersona overrides constructor defaults', async () => { const fix = makeFixture(); const otherFix = makeFixture(); diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts index f2e556e02..c11f638e3 100644 --- a/packages/sdk/src/personas.ts +++ b/packages/sdk/src/personas.ts @@ -220,8 +220,14 @@ export function findPersona( if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } if (candidateBytes !== undefined) { - const spec = parsePersonaJson(JSON.parse(candidateBytes), candidate); - if (spec.id === id) return { id, path: candidate, spec }; + try { + const spec = parsePersonaJson(JSON.parse(candidateBytes), candidate); + if (spec.id === id) return { id, path: candidate, spec }; + } catch { + // A bad shadow file at the conventional path shouldn't block a + // valid persona lower in the cascade. The directory-scan fallback + // below already tolerates parse failures the same way. + } } let entries: string[]; try { diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 98266ce25..9308386de 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -949,10 +949,11 @@ export class AgentRelay { // Persona-kit's plan.env carries persona-author env + resolved inputs. // The broker spawnPty API does not yet accept a per-spawn env; until - // that lands, surface any env beyond the harness's defaults so callers - // know they're being dropped. + // that lands, surface any env beyond the broker's effective ambient + // env so callers know they're being dropped. + const brokerEnv = this.clientOptions.env ?? process.env; const droppedEnv = Object.keys(plan.env ?? {}).filter( - (key) => process.env[key] !== plan.env[key], + (key) => brokerEnv[key] !== plan.env[key], ); if (droppedEnv.length > 0) { console.warn( @@ -990,7 +991,16 @@ export class AgentRelay { onError: options.onError, }); } catch (err) { - await handle.dispose(); + // Reverse persona side effects, but never let a dispose failure mask + // the original spawn error — that's the actionable one for callers. + try { + await handle.dispose(); + } catch (disposeErr) { + const msg = (disposeErr as Error)?.message ?? String(disposeErr); + console.warn( + `[AgentRelay] persona "${spec.id}" dispose after spawn failure failed: ${msg}`, + ); + } throw err; } @@ -1010,21 +1020,31 @@ export class AgentRelay { * inspect the persona's skill installs, mount policy, sidecars, and * harness argv before committing to a spawn. * + * Honors `options.persona` the same way {@link spawnPersona} does — a + * caller-supplied {@link PersonaSpec} short-circuits the search-dir + * cascade so dry-runs match what `spawnPersona` would actually execute. + * * Performs no filesystem writes and spawns no subprocesses. */ getPersonaSpawnPlan( personaId: string, options: SpawnPersonaOptions = {}, ): PersonaSpawnPlan { + const planOptions = { + ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), + ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), + ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), + }; + if (options.persona) { + return buildPersonaSpawnPlan(resolvePersona(options.persona), planOptions); + } const personaCwd = options.personaCwd ?? options.cwd ?? process.cwd(); const searchDirs = options.searchDirs ?? this.defaultPersonaDirs; const callOptions: PersonaSpawnPlanOptions = { cwd: personaCwd, ...(searchDirs ? { searchDirs } : {}), ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}), - ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), - ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), - ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), + ...planOptions, }; return getPersonaSpawnPlanImpl(personaId, callOptions); } From 138d8e466adf4e7673dd615140638b1f84712d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 19:22:22 +0000 Subject: [PATCH 3/4] =?UTF-8?q?sdk:=20forward-compat=20persona-kit=20?= =?UTF-8?q?=E2=89=A53.0.20=20(optional=20harness/model)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persona-kit 3.0.20 made PersonaSpec.harness / .model / .systemPrompt optional for handler-style (onEvent-driven) personas. Relay only spawns interactive personas, so resolvePersona now narrows those fields with explicit runtime guards that throw a clear error instead of letting tsc fail or the cast silently drop the fields. Bumps the SDK's persona-kit range to ^3.0.20 so the guard's narrowing is actually exercised against the new optional shape. Fixes the 'Publish Fresh Install Build' CI break that pulled 3.0.20 via the semver range and failed tsc on the previous resolvePersona which assigned `Harness | undefined` into a `Harness` field. --- package-lock.json | 44 ++++++++++----------- packages/sdk/package.json | 2 +- packages/sdk/src/__tests__/personas.test.ts | 17 ++++++++ packages/sdk/src/personas.ts | 25 ++++++++++-- 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 791e9d36b..753350669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -193,27 +193,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@agentworkforce/persona-kit": { - "version": "3.0.19", - "resolved": "https://registry.npmjs.org/@agentworkforce/persona-kit/-/persona-kit-3.0.19.tgz", - "integrity": "sha512-HWyHeMdScfJSRnsfcGIzgVD9dbVlFYCJYXRxC43AhUhy+7CSqBwlgVRWFJHLXDL9AdJn9lAkhtX8+Jp8zt2bbA==", - "dependencies": { - "@relayfile/local-mount": "^0.7.24" - } - }, - "node_modules/@agentworkforce/persona-kit/node_modules/@relayfile/local-mount": { - "version": "0.7.38", - "resolved": "https://registry.npmjs.org/@relayfile/local-mount/-/local-mount-0.7.38.tgz", - "integrity": "sha512-eRQmKQwexfstqW21B3YCI4sKQOEHGL/lLqSsE2FRA8rl67qPomugWPpyG9gliYIMkGKYQr7CG6e9Mghx12T1nw==", - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.5.6", - "ignore": "^7.0.5" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -17235,7 +17214,7 @@ "@agent-relay/github-primitive": "7.1.0", "@agent-relay/slack-primitive": "7.1.0", "@agent-relay/workflow-types": "7.1.0", - "@agentworkforce/persona-kit": "^3.0.19", + "@agentworkforce/persona-kit": "^3.0.20", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", @@ -17298,6 +17277,27 @@ } } }, + "packages/sdk/node_modules/@agentworkforce/persona-kit": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@agentworkforce/persona-kit/-/persona-kit-3.0.20.tgz", + "integrity": "sha512-TDsLR0caEOroyj2w5koSUMzxnwWns1uXVsS0jbc5ae0LkAZge6kRDAhqy1CLzpNW5IJg/LrmTu198SVzv0Zo2A==", + "dependencies": { + "@relayfile/local-mount": "^0.7.24" + } + }, + "packages/sdk/node_modules/@relayfile/local-mount": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/@relayfile/local-mount/-/local-mount-0.7.38.tgz", + "integrity": "sha512-eRQmKQwexfstqW21B3YCI4sKQOEHGL/lLqSsE2FRA8rl67qPomugWPpyG9gliYIMkGKYQr7CG6e9Mghx12T1nw==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.6", + "ignore": "^7.0.5" + }, + "engines": { + "node": ">=18" + } + }, "packages/slack-primitive": { "name": "@agent-relay/slack-primitive", "version": "7.1.0", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a78e6d081..17015b681 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -170,7 +170,7 @@ "@agent-relay/github-primitive": "7.1.0", "@agent-relay/slack-primitive": "7.1.0", "@agent-relay/workflow-types": "7.1.0", - "@agentworkforce/persona-kit": "^3.0.19", + "@agentworkforce/persona-kit": "^3.0.20", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts index d9059509e..2d23ab32a 100644 --- a/packages/sdk/src/__tests__/personas.test.ts +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -184,6 +184,23 @@ test('loadPersona reports "not found" when no valid persona with that id exists' } }); +test('resolvePersona rejects handler-style personas missing harness/model/systemPrompt', () => { + // persona-kit ≥3.0.20 made these fields optional for onEvent-driven personas. + // Relay only spawns interactive personas, so the guard must fire with a + // clear error rather than producing a malformed ResolvedPersona. + assert.throws( + () => + resolvePersona({ + id: 'handler-only', + intent: 'review', + description: 'cloud handler persona', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, + } as unknown as Parameters[0]), + /no harness/, + ); +}); + test('resolvePersona projects PersonaSpec into a PersonaSelection-shaped ResolvedPersona', () => { const fix = makeFixture(); try { diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts index c11f638e3..d08885c66 100644 --- a/packages/sdk/src/personas.ts +++ b/packages/sdk/src/personas.ts @@ -274,16 +274,35 @@ export function loadPersona(id: string, options: PersonaLoadOptions = {}): Perso * {@link ResolvedPersona} (persona-kit's spawn-input form). Used as glue * between {@link loadPersona} and {@link buildPersonaSpawnPlan}. * + * persona-kit ≥3.0.20 makes `harness` / `model` / `systemPrompt` optional + * on {@link PersonaSpec} for handler-style (`onEvent`-driven) personas + * that never spawn a harness directly. Relay only spawns interactive + * personas, so the missing fields are rejected with a clear error rather + * than letting the cast fail silently downstream. + * * Relay has no routing/selection layer, so the `rationale` field is left * empty. */ export function resolvePersona(spec: PersonaSpec): ResolvedPersona { + const { harness, model, systemPrompt } = spec; + if (!harness) { + throw new Error( + `Persona "${spec.id}" has no harness — relay only spawns interactive personas. ` + + 'Handler-style (onEvent-driven) personas should be deployed via the workforce CLI.', + ); + } + if (!model) { + throw new Error(`Persona "${spec.id}" has no model.`); + } + if (!systemPrompt) { + throw new Error(`Persona "${spec.id}" has no systemPrompt.`); + } const sidecar = resolveSidecar(spec); return { personaId: spec.id, - harness: spec.harness, - model: spec.model, - systemPrompt: spec.systemPrompt, + harness, + model, + systemPrompt, harnessSettings: spec.harnessSettings, skills: spec.skills, rationale: '', From 207c11d545e2d472ba4a9bc4d8b6500468583d9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 18:58:12 +0000 Subject: [PATCH 4/4] fix(sdk): honor model override and harden persona shadow-path test Agent-Logs-Url: https://github.com/AgentWorkforce/relay/sessions/97e9d7f3-e4dc-4e56-984b-5eb5936a17a3 Co-authored-by: willwashburn <957608+willwashburn@users.noreply.github.com> --- packages/sdk/src/__tests__/personas.test.ts | 2 +- packages/sdk/src/relay.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts index 2d23ab32a..33b69352e 100644 --- a/packages/sdk/src/__tests__/personas.test.ts +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -347,7 +347,7 @@ test('findPersona skips a malformed shadow file at the conventional path', () => }); assert.ok(found, 'should fall through to the valid persona in the lower-priority dir'); assert.match(found?.path ?? '', /frontend\.json$/); - assert.notMatch(found?.path ?? '', new RegExp(shadowDir)); + assert.notEqual(found?.path, join(shadowDir, 'frontend.json')); } finally { otherFix.cleanup(); fix.cleanup(); diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 9308386de..26a2efe30 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -976,7 +976,7 @@ export class AgentRelay { args: mergedArgs, ...(task !== undefined ? { task } : {}), channels: options.channels, - model: plan.persona.model, + model: options.model ?? plan.persona.model, cwd: handle.cwd, team: options.team, agentToken: options.agentToken,