Skip to content
Merged
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
25 changes: 20 additions & 5 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,21 @@ export function resolveSystemPromptPlaceholders(prompt: string, harness: Harness
}

function buildSelection(spec: PersonaSpec, kind: 'repo' | 'local'): PersonaSelection {
// An interactive `agentworkforce agent` run spawns a harness session, which
// requires harness/model/systemPrompt. These are optional on the spec only
// for handler-style (onEvent) personas, which are deployed — not run
// interactively — so reject them here with a pointed error.
if (!spec.harness || !spec.model || spec.systemPrompt === undefined) {
throw new Error(
`persona "${spec.id}" cannot be run interactively: it omits ${[
!spec.harness && 'harness',
!spec.model && 'model',
spec.systemPrompt === undefined && 'systemPrompt'
]
.filter(Boolean)
.join(', ')}. Handler-style personas (onEvent) are deployed via \`agentworkforce deploy\`, not run with \`agentworkforce agent\`.`
);
}
const systemPrompt = resolveSystemPromptPlaceholders(spec.systemPrompt, spec.harness);
const sidecar = resolveSidecar(spec);
// Built-in personas: prefer the routing-profile rationale string so the
Expand Down Expand Up @@ -2491,8 +2506,8 @@ function collectPersonaRows(): PersonaListRow[] {
rows.push({
persona: spec.id,
source,
harness: spec.harness,
model: spec.model,
harness: spec.harness ?? '—',
model: spec.model ?? '—',
intent: spec.intent,
tags: spec.tags ?? [],
description: spec.description
Expand Down Expand Up @@ -2813,8 +2828,8 @@ function formatPersonaShow(spec: PersonaSpec, source: PersonaSource): string {

lines.push('');
lines.push('RUNTIME');
lines.push(` harness: ${spec.harness}`);
lines.push(` model: ${spec.model}`);
lines.push(` harness: ${spec.harness ?? '— (handler-style persona)'}`);
lines.push(` model: ${spec.model ?? '—'}`);
lines.push(` reasoning: ${spec.harnessSettings.reasoning}`);
lines.push(` timeout: ${spec.harnessSettings.timeoutSeconds}s`);
if (spec.harnessSettings.sandboxMode) {
Expand All @@ -2830,7 +2845,7 @@ function formatPersonaShow(spec: PersonaSpec, source: PersonaSource): string {
lines.push(` webSearch: ${spec.harnessSettings.webSearch}`);
}
lines.push(' systemPrompt:');
lines.push(indent(spec.systemPrompt, ' '));
lines.push(indent(spec.systemPrompt ?? '(none — handler-style persona)', ' '));

return lines.join('\n') + '\n';
}
Expand Down
16 changes: 14 additions & 2 deletions packages/deploy/src/modes/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ async function ensureHarnessReady(args: {
harnessSource?: HarnessSource;
byokKey?: string;
}): Promise<Record<string, string>> {
// Pure handler personas declare no harness (the bundled handler never calls
// ctx.harness.run — it orchestrates via ctx.workflow.run / integration
// clients). There's no harness inference to bill, so skip credential
// provisioning entirely.
if (!args.persona.harness) {
args.io.info('cloud: persona declares no harness; skipping harness credential setup');
return {};
}
const source = await resolveHarnessSource(args);
const modelProvider = deriveModelProvider(args.persona);
if (source === 'plan') {
Expand Down Expand Up @@ -716,16 +724,20 @@ async function saveProviderCredential(args: {
}

function deriveModelProvider(persona: PersonaSpec): string {
// Callers gate on `persona.harness` being set before reaching here, so the
// harness fallback is always a defined string in practice; default to
// 'anthropic' to keep the return type total.
const harnessFallback = persona.harness ?? 'anthropic';
const model = typeof persona.model === 'string' ? persona.model.trim() : '';
if (!model) return persona.harness;
if (!model) return harnessFallback;
const lower = model.toLowerCase();
if (matchesProviderToken(lower, ['anthropic', 'claude'])) return 'anthropic';
if (matchesProviderToken(lower, ['openai', 'codex', 'gpt'])) return 'openai';
if (matchesProviderToken(lower, ['google', 'gemini'])) return 'google';
if (matchesProviderToken(lower, ['openrouter', 'opencode'])) return 'openrouter';
const [provider] = model.split(/[/:]/, 1);
if (provider?.trim()) return provider.trim().toLowerCase();
return persona.harness;
return harnessFallback;
}

function matchesProviderToken(model: string, tokens: readonly string[]): boolean {
Expand Down
9 changes: 3 additions & 6 deletions packages/persona-kit/schemas/persona.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@
},
"harness": {
"$ref": "#/definitions/Harness",
"description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`)."
"description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`). Required for interactive personas. Optional for handler-style personas ( {@link onEvent } set): only consumed when the handler calls `ctx.harness.run(...)`; pure orchestrators omit it."
},
"model": {
"type": "string",
"description": "Model identifier passed to the harness."
"description": "Model identifier passed to the harness. Optional for handler-style personas — see {@link harness } ."
},
"systemPrompt": {
"type": "string",
"description": "System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time."
"description": "System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time. Optional for handler-style personas — see {@link harness } ."
},
"harnessSettings": {
"$ref": "#/definitions/HarnessSettings",
Expand Down Expand Up @@ -141,9 +141,6 @@
"intent",
"description",
"skills",
"harness",
"model",
"systemPrompt",
"harnessSettings"
],
"description": "A persona listens for events. Three listener kinds: clock (cron schedules through `schedules[]`), radio (RelayFile integration events through `integrations.<provider>.triggers[]`), and inbox (RelayCast targeted messages, not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent.",
Expand Down
68 changes: 68 additions & 0 deletions packages/persona-kit/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,74 @@ test('parsePersonaSpec throws when required runtime fields are missing', () => {
);
});

test('parsePersonaSpec requires harness/model/systemPrompt for interactive personas (no onEvent)', () => {
// Build a spec missing each runtime field with no onEvent — still required.
const base = {
id: 'p',
intent: 'documentation',
tags: ['documentation'],
description: 'd',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }
};
assert.throws(
() => parsePersonaSpec({ ...base, model: 'm', systemPrompt: 's' }, 'documentation'),
/persona\[documentation\]\.harness must be one of:/
);
assert.throws(
() => parsePersonaSpec({ ...base, harness: 'claude', systemPrompt: 's' }, 'documentation'),
/persona\[documentation\]\.model must be a non-empty string/
);
assert.throws(
() => parsePersonaSpec({ ...base, harness: 'claude', model: 'm' }, 'documentation'),
/persona\[documentation\]\.systemPrompt must be a non-empty string/
);
});

test('parsePersonaSpec allows handler personas (onEvent) to omit harness/model/systemPrompt', () => {
// A pure orchestrator: the bundled handler controls flow and never calls
// ctx.harness.run, so it carries no harness/model/systemPrompt.
const spec = parsePersonaSpec(
{
id: 'orchestrator',
intent: 'documentation',
tags: ['documentation'],
description: 'fans out to a workflow on each issue event',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 1800 },
cloud: true,
integrations: { github: { triggers: [{ on: 'issues.opened' }] } },
onEvent: './agent.ts'
},
'documentation'
);
assert.equal(spec.onEvent, './agent.ts');
assert.equal(spec.harness, undefined);
assert.equal(spec.model, undefined);
assert.equal(spec.systemPrompt, undefined);
});

test('parsePersonaSpec still validates harness enum for handler personas when provided', () => {
// Optional ≠ unvalidated: a handler that DOES call ctx.harness.run supplies
// harness/model, and a bad harness value is still rejected.
assert.throws(
() =>
parsePersonaSpec(
{
id: 'h',
intent: 'documentation',
tags: ['documentation'],
description: 'd',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 },
cloud: true,
schedules: [{ name: 'daily', cron: '0 6 * * *', tz: 'UTC' }],
onEvent: './agent.ts',
harness: 'mystery'
},
'documentation'
),
/persona\[documentation\]\.harness must be one of:/
);
});

test('parsePersonaSpec defers malformed skills[i].source to plan time (does not throw at parse)', () => {
// Issue 70 contract: parse only validates shape (string + non-empty); URL/source-kind
// validation happens in materializeSkills so a typo blows up its own persona, not the dir.
Expand Down
37 changes: 30 additions & 7 deletions packages/persona-kit/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,16 +879,39 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent):
throw new Error(`persona[${expectedIntent}].description must be a non-empty string`);
}

if (!isHarness(harness)) {
// Handler-style personas (`onEvent` set) run a bundled handler that
// controls the flow itself; `harness` / `model` / `systemPrompt` are only
// consumed when the handler calls `ctx.harness.run(...)`, so they're
// optional here. A pure orchestrator (e.g. one that only fans out to
// `ctx.workflow.run`) needs none of them and shouldn't have to carry stub
// values to pass validation. Interactive personas (no `onEvent`) still
// require all three — that's the harness session the CLI spawns.
const isHandlerPersona = typeof onEvent === 'string' && onEvent.trim().length > 0;

if (harness !== undefined) {
if (!isHarness(harness)) {
throw new Error(
`persona[${expectedIntent}].harness must be one of: ${HARNESS_VALUES.join(', ')}`
);
}
} else if (!isHandlerPersona) {
throw new Error(
`persona[${expectedIntent}].harness must be one of: ${HARNESS_VALUES.join(', ')}`
);
}
if (typeof model !== 'string' || !model.trim()) {
if (model !== undefined) {
if (typeof model !== 'string' || !model.trim()) {
throw new Error(`persona[${expectedIntent}].model must be a non-empty string`);
}
} else if (!isHandlerPersona) {
throw new Error(`persona[${expectedIntent}].model must be a non-empty string`);
}
const trimmedModel = model.trim();
if (typeof systemPrompt !== 'string' || !systemPrompt.trim()) {
const trimmedModel = typeof model === 'string' ? model.trim() : undefined;
if (systemPrompt !== undefined) {
if (typeof systemPrompt !== 'string' || !systemPrompt.trim()) {
throw new Error(`persona[${expectedIntent}].systemPrompt must be a non-empty string`);
}
} else if (!isHandlerPersona) {
throw new Error(`persona[${expectedIntent}].systemPrompt must be a non-empty string`);
}
const parsedHarnessSettings = parseHarnessSettings(
Expand Down Expand Up @@ -953,9 +976,9 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent):
description,
skills: parsedSkills,
...(parsedInputs ? { inputs: parsedInputs } : {}),
harness,
model: trimmedModel,
systemPrompt,
...(harness !== undefined ? { harness: harness as Harness } : {}),
...(trimmedModel !== undefined ? { model: trimmedModel } : {}),
...(systemPrompt !== undefined ? { systemPrompt: systemPrompt as string } : {}),
harnessSettings: parsedHarnessSettings,
...(parsedEnv ? { env: parsedEnv } : {}),
...(parsedMcpServers ? { mcpServers: parsedMcpServers } : {}),
Expand Down
17 changes: 11 additions & 6 deletions packages/persona-kit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,17 @@ export interface PersonaSpec {
* values are substituted into the persona's system prompt.
*/
inputs?: Record<string, PersonaInputSpec>;
/** Harness binary used to run this persona (`claude`, `codex`, `opencode`). */
harness: Harness;
/** Model identifier passed to the harness. */
model: string;
/** System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time. */
systemPrompt: string;
/**
* Harness binary used to run this persona (`claude`, `codex`, `opencode`).
* Required for interactive personas. Optional for handler-style personas
* ({@link onEvent} set): only consumed when the handler calls
* `ctx.harness.run(...)`; pure orchestrators omit it.
*/
harness?: Harness;
/** Model identifier passed to the harness. Optional for handler-style personas — see {@link harness}. */
model?: string;
/** System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time. Optional for handler-style personas — see {@link harness}. */
systemPrompt?: string;
/** Harness-level knobs (reasoning, timeout, codex sandbox/approval policy, etc.). */
harnessSettings: HarnessSettings;
/**
Expand Down
26 changes: 23 additions & 3 deletions packages/runtime/src/cloud-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
env: NodeJS.ProcessEnv;
}): (run: HarnessRunArgs) => Promise<HarnessRunResult> {
return async (run) => {
// harness/model/systemPrompt are optional on the persona spec (pure
// orchestrator handlers omit them). But a handler that actually calls
// ctx.harness.run() needs all three to spawn the session — fail with a
// pointed error rather than passing `undefined` into the spec builder.
if (!args.persona.harness || !args.persona.model || args.persona.systemPrompt === undefined) {
throw new Error(
`ctx.harness.run() requires the persona to declare harness, model, and systemPrompt — ` +
`persona "${args.persona.id}" omits ${[
!args.persona.harness && 'harness',
!args.persona.model && 'model',
args.persona.systemPrompt === undefined && 'systemPrompt'
]
.filter(Boolean)
.join(', ')}. Add them to persona.json, or remove the ctx.harness.run() call.`
);
}
const harness = args.persona.harness;
const personaModel = args.persona.model;
const personaSystemPrompt = args.persona.systemPrompt;

const inputValues = resolveAgentInputValues(args.agent);
const inputResolution = resolvePersonaInputs(args.persona.inputs, inputValues, args.env);
const callerEnv = { ...args.env, ...inputResolution.values };
Expand All @@ -175,7 +195,7 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
args.log('warn', 'harness.config.dropped', { warning });
}

const renderedSystemPrompt = renderPersonaInputs(args.persona.systemPrompt, inputResolution.values);
const renderedSystemPrompt = renderPersonaInputs(personaSystemPrompt, inputResolution.values);
const cwd = resolveWorkspacePath(args.workspaceRoot, run.cwd ?? args.workspaceRoot);
await assertDirectory(cwd);
await materializeSidecar({
Expand All @@ -186,9 +206,9 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
});
const task = run.prompt;
const spec = buildNonInteractiveSpec({
harness: args.persona.harness,
harness,
personaId: args.persona.id,
model: args.persona.model,
model: personaModel,
systemPrompt: renderedSystemPrompt,
harnessSettings: args.persona.harnessSettings,
mcpServers: mcpResolution.servers,
Expand Down
10 changes: 10 additions & 0 deletions packages/workload-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ export function resolvePersona(intent: PersonaIntent, profile: RoutingProfile |
const rule = profileSpec.intents[intent];
const spec = requireBuiltInPersona(intent);

// Routing resolves to an interactive harness session, which requires a
// harness/model/systemPrompt. Built-in catalog personas always declare
// them; the guard narrows the now-optional spec fields and fails loudly
// if a malformed built-in ever slips through.
if (!spec.harness || !spec.model || !spec.systemPrompt) {
throw new Error(
`built-in persona "${spec.id}" (intent ${intent}) is missing harness/model/systemPrompt required for routing`
);
}

return {
personaId: spec.id,
harness: spec.harness,
Expand Down
Loading