-
Notifications
You must be signed in to change notification settings - Fork 0
Compose top-level persona-kit orchestration API #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c74f180
Compose top-level persona-kit orchestration API
claude c2c9fd9
Address PR review feedback on persona-kit orchestration API
claude fa14ddb
Address follow-up review feedback
claude e500212
Use process.execPath in skill-runner tests for platform-agnostic spaw…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,93 @@ | ||
| # @agentworkforce/persona-kit | ||
|
|
||
| Persona-kit owns the AgentWorkforce persona instantiation lifecycle. See tracking issue [#64](https://github.com/AgentWorkforce/workforce/issues/64). | ||
|
|
||
| ## Top-level orchestration API | ||
|
|
||
| The package exposes a two-phase API: a **pure plan builder** that composes a | ||
| persona's runtime data into a single inspectable value, and a **side-effecting | ||
| executor** that runs the plan and returns a handle that reverses every side | ||
| effect. | ||
|
|
||
| ### Persona JSON → running agent (end-to-end) | ||
|
|
||
| ```ts | ||
| import { | ||
| buildPersonaSpawnPlan, | ||
| executePersonaSpawnPlan, | ||
| type ResolvedPersona | ||
| } from '@agentworkforce/persona-kit'; | ||
| import { spawn } from 'node:child_process'; | ||
|
|
||
| declare const persona: ResolvedPersona; // from loadPersonas() / personaCatalog | ||
|
|
||
| // 1. Compose a plan. Pure — no I/O, no subprocesses. | ||
| // PlanOptions: inputValues, envOverrides, processEnv, installRoot. | ||
| // cwd belongs to ExecuteOptions, not the plan builder. | ||
| const plan = buildPersonaSpawnPlan(persona, { | ||
| inputValues: { TASK: 'tidy up the README' } | ||
| }); | ||
|
|
||
| // 2. Inspect or stamp the plan if you like — it's JSON-serializable. | ||
| console.log(plan.cli, plan.args, plan.env); | ||
|
|
||
| // 3. Run the side effects. Returns a handle whose dispose() reverses them. | ||
| const handle = await executePersonaSpawnPlan(plan, { cwd: process.cwd() }); | ||
|
|
||
| try { | ||
| // 4. Spawn the harness at handle.cwd with plan.cli + plan.args + plan.env. | ||
| await new Promise<void>((resolve, reject) => { | ||
| const child = spawn(plan.cli, plan.args, { | ||
| cwd: handle.cwd, | ||
| env: plan.env, | ||
| stdio: 'inherit' | ||
| }); | ||
| child.on('error', reject); | ||
| child.on('close', () => resolve()); | ||
| }); | ||
| } finally { | ||
| // 5. Tear down: removes installed skills, restores sidecar files, | ||
| // cleans up materialized config files, releases the mount. | ||
| await handle.dispose(); | ||
| } | ||
| ``` | ||
|
|
||
| ### Plan structure | ||
|
|
||
| `PersonaSpawnPlan` carries everything a caller needs: | ||
|
|
||
| - `persona` — the resolved persona (model, harness, skills, env, …). | ||
| - `cli`, `args`, `initialPrompt` — what to spawn. | ||
| - `env` — final environment with input bindings + persona env merged in. | ||
| - `skills` — pure `SkillMaterializationPlan` produced by `materializeSkills`. | ||
| - `mount`, `sidecars`, `configFiles`, `inputs` — resolved pieces ready for | ||
| the executor. | ||
|
|
||
| The plan is **JSON-serializable** — useful for stamping into launch metadata | ||
| or sending across a wire (e.g. relay's `getPersonaSpawnPlan`). | ||
|
|
||
| ### Piecewise helpers | ||
|
|
||
| For callers who want their own orchestration: | ||
|
|
||
| - `applyPersonaMount(mount, options)` — opens an `@relayfile/local-mount` | ||
| sandbox when a mount policy is supplied, no-op otherwise. Returns a handle | ||
| exposing the harness `cwd`. | ||
| - `runSkillInstalls(plan, options)` — spawns the install commands produced | ||
| by `buildInstallArtifacts`. Aborts on the first non-zero exit and attaches | ||
| the buffered subprocess output to `SkillInstallError`. | ||
| - `writePersonaSidecars(sidecars, options)` — writes `CLAUDE.md` / | ||
| `AGENTS.md` with restore-on-dispose semantics. | ||
| - `materializePersonaConfigFiles(configFiles, options)` — writes the harness | ||
| config files (e.g. `opencode.json`) with restore-on-dispose semantics. | ||
|
|
||
| Each helper returns a handle of shape `{ dispose(): Promise<void> }`. | ||
| `executePersonaSpawnPlan` composes them in order: mount → skills → sidecars | ||
| → config files. If any step throws, prior handles are disposed in LIFO | ||
| order before the original error propagates. | ||
|
|
||
| ### Pure lower-level helpers | ||
|
|
||
| `buildInteractiveSpec`, `materializeSkills`, `parsePersonaFile`, | ||
| `resolvePersonaInputs`, etc. remain available for advanced callers who want | ||
| finer control than the plan builder offers. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; | ||
| import { dirname, isAbsolute, join } from 'node:path'; | ||
| import type { InteractiveConfigFile } from './interactive-spec.js'; | ||
|
|
||
| export interface PersonaConfigFilesHandle { | ||
| /** Reverse every config-file write. Idempotent. */ | ||
| dispose(): Promise<void>; | ||
| } | ||
|
|
||
| interface RestoredFile { | ||
| path: string; | ||
| prior: string | null; | ||
| } | ||
|
|
||
| /** | ||
| * Reject paths that would escape the cwd or hit absolute targets — same | ||
| * policy as the CLI's existing `assertSafeRelativePath` so persona-kit's | ||
| * exec helpers can be plugged into the CLI later without weakening the | ||
| * sandbox guarantees. | ||
| */ | ||
| export function assertSafeRelativePath(relPath: string): void { | ||
| if (!relPath) { | ||
| throw new Error('configFile path must be a non-empty relative path'); | ||
| } | ||
| if (isAbsolute(relPath)) { | ||
| throw new Error( | ||
| `configFile path must be relative; got absolute path ${JSON.stringify(relPath)}` | ||
| ); | ||
| } | ||
| const segments = relPath.split(/[\\/]+/); | ||
| if (segments.some((s) => s === '..')) { | ||
| throw new Error( | ||
| `configFile path must not contain ".." segments; got ${JSON.stringify(relPath)}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| async function readIfExists(path: string): Promise<string | null> { | ||
| try { | ||
| return await readFile(path, 'utf8'); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Materialize each {@link InteractiveConfigFile} under `options.cwd`, | ||
| * creating any missing parent directories. The returned handle restores | ||
| * each touched path to its prior state on `dispose()`. | ||
| */ | ||
| export async function materializePersonaConfigFiles( | ||
| configFiles: readonly InteractiveConfigFile[], | ||
| options: { cwd: string } | ||
| ): Promise<PersonaConfigFilesHandle> { | ||
| const restored: RestoredFile[] = []; | ||
| let disposed = false; | ||
| try { | ||
| for (const file of configFiles) { | ||
| assertSafeRelativePath(file.path); | ||
| const target = join(options.cwd, file.path); | ||
| const prior = await readIfExists(target); | ||
| restored.push({ path: target, prior }); | ||
| await mkdir(dirname(target), { recursive: true }); | ||
| await writeFile(target, file.contents, 'utf8'); | ||
| } | ||
| } catch (err) { | ||
| await disposeRestored(restored); | ||
| throw err; | ||
| } | ||
| return { | ||
| async dispose(): Promise<void> { | ||
| if (disposed) return; | ||
| disposed = true; | ||
| await disposeRestored(restored); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| async function disposeRestored(restored: readonly RestoredFile[]): Promise<void> { | ||
| for (let i = restored.length - 1; i >= 0; i -= 1) { | ||
| const entry = restored[i]; | ||
| try { | ||
| if (entry.prior === null) { | ||
| await unlink(entry.path).catch((err: NodeJS.ErrnoException) => { | ||
| if (err.code !== 'ENOENT') throw err; | ||
| }); | ||
| } else { | ||
| await writeFile(entry.path, entry.prior, 'utf8'); | ||
| } | ||
| } catch { | ||
| // Best-effort. | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.