Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions examples/review-agent/persona.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
{
"id": "review-agent",
"intent": "review",
"tags": ["review"],
"tags": [
"review"
],
"description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.",
"cloud": true,
"useSubscription": true,
"integrations": {
"github": {
"triggers": [
{ "on": "pull_request.opened" },
{ "on": "issue_comment.created", "match": "@mention" },
{ "on": "pull_request_review_comment.created", "match": "@mention" },
{ "on": "check_run.completed", "where": "conclusion=failure" }
{
"on": "pull_request.opened"
},
{
"on": "issue_comment.created",
"match": "@mention"
},
{
"on": "pull_request_review_comment.created",
"match": "@mention"
},
{
"on": "check_run.completed",
"where": "conclusion=failure"
}
]
},
"slack": {
"triggers": [{ "on": "app_mention" }]
"triggers": [
{
"on": "app_mention"
}
]
}
},
"memory": {
"enabled": true,
"scopes": ["workspace"]
"scopes": [
"workspace"
]
},
"onEvent": "./agent.ts",
"harness": "codex",
Expand Down
39 changes: 39 additions & 0 deletions examples/review-agent/persona.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { definePersona } from '@agentworkforce/persona-kit';

export default definePersona({
id: 'review-agent',
intent: 'review',
tags: ['review'],
description:
'Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.',
cloud: true,
useSubscription: true,
integrations: {
github: {
triggers: [
{ on: 'pull_request.opened' },
{ on: 'issue_comment.created', match: '@mention' },
{ on: 'pull_request_review_comment.created', match: '@mention' },
{ on: 'check_run.completed', where: 'conclusion=failure' }
]
},
slack: {
triggers: [{ on: 'app_mention' }]
}
},
memory: {
enabled: true,
scopes: ['workspace']
},
onEvent: './agent.ts',
harness: 'codex',
model: 'gpt-5.4',
systemPrompt:
'Review pull requests for correctness, regression risk, security concerns, and missing tests. Be concise and concrete.',
harnessSettings: {
reasoning: 'medium',
timeoutSeconds: 1200,
sandboxMode: 'workspace-write',
workspaceWriteNetworkAccess: true
}
});
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@agentworkforce/workload-router": "workspace:*",
"@relayburn/sdk": "^2.5.2",
"@relayfile/local-mount": "^0.7.24",
"esbuild": "^0.25.0",
"ora": "^9.4.0"
},
"repository": {
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
type PersonaSource
} from './local-personas.js';
import { installPersonas, type PersonaInstallResult } from './persona-install.js';
import { runPersonaCompileCommand } from './persona-compile.js';
import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js';
import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js';

Expand Down Expand Up @@ -191,6 +192,9 @@ Commands:
including which cascade layer defined it (cwd, user,
dir:<n>, library). Flags:
--json emit the resolved PersonaSpec as JSON
persona compile <path/to/persona.ts>
Compile a typed persona.ts authoring file to sibling
persona.json after validating it with persona-kit.
install [flags] <pkg|path>
Copy persona JSON files from an npm package or local
package directory into
Expand Down Expand Up @@ -4268,6 +4272,15 @@ export async function main(): Promise<void> {
runShow(rest);
}

if (subcommand === 'persona') {
try {
await runPersonaCompileCommand(rest);
return;
} catch (err) {
die((err as Error)?.message ?? String(err), false);
}
}

if (subcommand === 'install') {
runPersonaInstall(rest);
}
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/persona-compile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { compilePersonaFile } from './persona-compile.js';

test('compilePersonaFile bundles persona.ts, validates it, and writes persona.json', async () => {
const root = mkdtempSync(join(tmpdir(), 'aw-persona-compile-'));
try {
const inputPath = join(root, 'persona.ts');
const outputPath = join(root, 'persona.json');
writeFileSync(
inputPath,
`import { definePersona } from '@agentworkforce/persona-kit';

export default definePersona({
id: 'compiled-persona',
intent: 'review',
description: 'Compiled persona fixture.',
inputs: {
TARGET: 'repo'
},
integrations: {
github: {
triggers: [
{ on: 'pull_request.opened' },
{ on: 'off_registry.github_event' }
]
},
linear: { triggers: [{ on: 'issue.updated' }] },
slack: { triggers: [{ on: 'message.channels' }] },
notion: { triggers: [{ on: 'page.created' }] },
jira: { triggers: [{ on: 'issue.created' }] },
unknown: { triggers: [{ on: 'whatever.happened' }] }
},
onEvent: './agent.ts',
harnessSettings: {
reasoning: 'medium',
timeoutSeconds: 60
}
});
`,
'utf8'
);

const result = await compilePersonaFile(inputPath);
const compiled = JSON.parse(readFileSync(outputPath, 'utf8')) as {
id: string;
inputs?: Record<string, unknown>;
integrations?: Record<string, { triggers?: Array<{ on: string }> }>;
};

assert.equal(result.personaId, 'compiled-persona');
assert.equal(result.outputPath, outputPath);
assert.equal(compiled.id, 'compiled-persona');
assert.equal(compiled.inputs?.TARGET, 'repo');
assert.equal(
compiled.integrations?.github.triggers?.[1].on,
'off_registry.github_event'
);
assert.equal(compiled.integrations?.unknown.triggers?.[0].on, 'whatever.happened');
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('compilePersonaFile fails loudly when validation rejects the authored spec', async () => {
const root = mkdtempSync(join(tmpdir(), 'aw-persona-compile-invalid-'));
try {
const inputPath = join(root, 'persona.ts');
writeFileSync(
inputPath,
`export default {
id: 'bad',
intent: 'review',
description: 'Bad persona fixture.',
harnessSettings: { reasoning: 'turbo', timeoutSeconds: 60 },
onEvent: './agent.ts'
};
`,
'utf8'
);

await assert.rejects(
() => compilePersonaFile(inputPath),
/harnessSettings\.reasoning must be low\|medium\|high/
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
117 changes: 117 additions & 0 deletions packages/cli/src/persona-compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { builtinModules } from 'node:module';
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { isIntent, isObject, parsePersonaSpec } from '@agentworkforce/persona-kit';
import { build } from 'esbuild';

export interface PersonaCompileResult {
inputPath: string;
outputPath: string;
personaId: string;
}

const NODE_EXTERNALS = [
...builtinModules,
...builtinModules.map((name) => `node:${name}`),
'node:*'
];

export async function compilePersonaFile(
inputPath: string,
outputPath?: string
): Promise<PersonaCompileResult> {
const absInput = resolve(inputPath);
await assertReadableFile(absInput, 'persona compile input');

const absOutput = resolve(outputPath ?? join(dirname(absInput), 'persona.json'));
const tempDir = await mkdtemp(join(tmpdir(), 'agentworkforce-persona-compile-'));
const bundledPath = join(tempDir, `${basename(absInput).replace(/\W+/g, '-')}.mjs`);

try {
await build({
entryPoints: [absInput],
outfile: bundledPath,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
sourcemap: 'inline',
logLevel: 'silent',
external: NODE_EXTERNALS,
resolveExtensions: ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json'],
nodePaths: packageNodePaths()
});

const mod = await import(pathToFileURL(bundledPath).href);
const spec = mod.default as unknown;
if (!isObject(spec)) {
throw new Error('persona compile: default export must be a persona object');
}
if (typeof spec.intent !== 'string') {
throw new Error('persona compile: default export must include a string intent');
}
if (!isIntent(spec.intent)) {
throw new Error(`persona compile: intent "${spec.intent}" is invalid`);
}
const parsed = parsePersonaSpec(spec, spec.intent);

await mkdir(dirname(absOutput), { recursive: true });
await writeFile(absOutput, JSON.stringify(spec, null, 2) + '\n', 'utf8');

return {
inputPath: absInput,
outputPath: absOutput,
personaId: parsed.id
};
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}

export async function runPersonaCompileCommand(args: string[]): Promise<void> {
const [action, personaPath, ...rest] = args;
if (!action || action === '-h' || action === '--help') {
process.stdout.write('Usage: agentworkforce persona compile <path/to/persona.ts>\n');
process.exit(action ? 0 : 1);
}
if (action !== 'compile') {
throw new Error(`persona: unknown action "${action}". Expected: compile`);
}
if (!personaPath) {
throw new Error('persona compile: missing <path/to/persona.ts>');
}
if (rest.length > 0) {
throw new Error(`persona compile: unexpected argument "${rest[0]}"`);
}

const result = await compilePersonaFile(personaPath);
process.stdout.write(
`Compiled ${result.inputPath} -> ${result.outputPath} (${result.personaId})\n`
);
}

async function assertReadableFile(abs: string, label: string): Promise<void> {
try {
const st = await stat(abs);
if (!st.isFile()) {
throw new Error(`${label}: ${abs} is not a regular file`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`${label}: file not found at ${abs}`);
}
throw err;
}
}

function packageNodePaths(): string[] {
const here = dirname(fileURLToPath(import.meta.url));
return [
join(here, '..', 'node_modules'),
join(here, '..', '..', 'node_modules'),
join(process.cwd(), 'node_modules')
];
}
Loading
Loading