diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a21c0826..fb8d69dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,18 +10,23 @@ 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 spawn` and SDK spawn calls now return harness `sessionId` metadata for resumable Claude and Codex PTY sessions. +- `@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. - `agent-relay-broker`'s public Rust protocol types now require typed ID newtypes (`WorkerName`, `DeliveryId`, `EventId`, `WorkspaceId`, `WorkspaceAlias`, `ThreadId`, `AgentId`, `RequestId`, `ChannelName`, `MessageTarget`) on every protocol struct and enum variant in `protocol.rs`, `types.rs`, and `listen_api.rs::ListenApiRequest`. The new wrappers live in `crates/broker/src/lib.rs` under `pub mod ids`. JSON wire format is unchanged because every wrapper is `#[serde(transparent)]`, so the broker ↔ SDK channel and on-disk persisted state remain byte-compatible. +- `agent-relay spawn` and SDK spawn calls now return harness `sessionId` metadata for resumable Claude and Codex PTY sessions. ### 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. - Downstream Rust callers must construct identifiers via `relay_broker::ids::{WorkerName, DeliveryId, EventId, MessageTarget, …}` instead of `String`. Each newtype impls `From` / `From<&str>` and `Deref`, so most string-handling code keeps compiling; only construction sites (`HashMap` keys, struct literals, channel sends) need updates. - Replace ad-hoc target discrimination (`target.starts_with('#')`, `target == "thread"`) with `MessageTarget::kind()` and match on `MessageTargetKind::{Channel, Thread, DirectMessage, Conversation, Worker}`. diff --git a/package-lock.json b/package-lock.json index a79b1d6c9..40ed2fe92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -194,19 +194,6 @@ "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==", - "dependencies": { - "@agentworkforce/workload-router": "0.11.0" - } - }, - "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/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -3723,6 +3710,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", @@ -9808,7 +10090,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" @@ -9833,7 +10114,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" @@ -12184,6 +12464,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", @@ -12710,7 +12996,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" @@ -16977,8 +17262,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.20", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", @@ -17041,6 +17325,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 a31a292ab..0d1c8e40c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -170,8 +170,7 @@ "@agent-relay/github-primitive": "7.1.1", "@agent-relay/slack-primitive": "7.1.1", "@agent-relay/workflow-types": "7.1.1", - "@agentworkforce/harness-kit": "^0.11.0", - "@agentworkforce/workload-router": "^0.11.0", + "@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 6840e6500..33b69352e 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.', - }, - 'best-value': { - harness: 'codex', - model: 'openai-codex/gpt-5-codex', - systemPrompt: 'You are an efficient 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' }, }, - minimum: { - harness: 'opencode', - model: 'opencode/gpt-5-nano', - systemPrompt: 'You are a concise frontend engineer.', - }, - }, - permissions: { - allow: ['Bash(npm test)'], - mode: 'default', - }, - }), + }), + ), ); - // 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, '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.', + }), + ), + ); + + writeFileSync( + 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,172 @@ 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 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 { - 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 }), + /not found/, + ); } finally { fix.cleanup(); } }); -test('buildPersonaSpawnSpec for claude includes system prompt and MCP flags', () => { +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 { - 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,18 +312,48 @@ 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(); } }); +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.notEqual(found?.path, join(shadowDir, 'frontend.json')); + } finally { + otherFix.cleanup(); + fix.cleanup(); + } +}); + test('per-call searchDirs on spawnPersona overrides constructor defaults', async () => { const fix = makeFixture(); const otherFix = makeFixture(); @@ -305,6 +384,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 +394,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..d08885c66 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,18 +215,19 @@ 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); - 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 { @@ -258,7 +239,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 +249,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 +264,93 @@ export function loadPersona(id: string, options: PersonaLoadOptions = {}): Resol 'Set searchDirs / extraDirs to include the directory containing the persona file.', ); } + return discovered.spec; +} - 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; +// ── Resolution ───────────────────────────────────────────────────────────── +/** + * 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}. + * + * 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 "${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(', ')}.`, + `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 "${id}" tier "${tier}" has no model; set tiers.${tier}.model or top-level model.`, - ); + throw new Error(`Persona "${spec.id}" has no model.`); } if (!systemPrompt) { - throw new Error( - `Persona "${id}" tier "${tier}" has no systemPrompt; set tiers.${tier}.systemPrompt or top-level systemPrompt.`, - ); + throw new Error(`Persona "${spec.id}" has no systemPrompt.`); } - + const sidecar = resolveSidecar(spec); return { - id: spec.id, - source: discovered.path, - tier, + personaId: spec.id, harness, model, systemPrompt, - description: spec.description, - permissions: spec.permissions, - mcpServers: spec.mcpServers, - }; -} - -// ── 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, + 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), }; } -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 ?? [])]); - return { - ...(allow.length > 0 ? { allow } : {}), - ...(deny.length > 0 ? { deny } : {}), - ...(override?.mode ?? base?.mode ? { mode: override?.mode ?? base?.mode } : {}), - }; -} - -// ── 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 52803b762..b4f7f5a89 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -40,14 +40,17 @@ import { 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'; @@ -316,8 +319,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 @@ -333,10 +334,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 }; @@ -893,17 +911,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, @@ -915,34 +936,57 @@ 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 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) => brokerEnv[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: options.model ?? plan.persona.model, + cwd: handle.cwd, team: options.team, agentToken: options.agentToken, shadowOf: options.shadowOf, @@ -956,19 +1000,64 @@ export class AgentRelay { onError: options.onError, }); } catch (err) { - restorePersonaConfigFiles(writes); + // 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; } - 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. + * + * 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 } : {}), + ...planOptions, + }; + return getPersonaSpawnPlanImpl(personaId, callOptions); + } + // ── Human source ──────────────────────────────────────────────────────── human(opts: { name: string }): HumanHandle {