From dedf319f26e4c813d780fa8125aca6d61f5de041 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 23 May 2026 20:22:00 +0200 Subject: [PATCH] feat(persona-kit): make harness/model/systemPrompt optional for handler personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler-style personas (`onEvent` set) run a bundled handler that controls flow themselves. `harness` / `model` / `systemPrompt` are only consumed when the handler calls `ctx.harness.run(...)`; a pure orchestrator (e.g. one that only fans out to `ctx.workflow.run`) needs none of them. Until now the parser required all three regardless, so authors carried stub values purely to pass validation (see cloud's cloud-small-issue-codex persona, whose systemPrompt is literally "required by persona-kit validation but is not used by the bundled handler"). Change: - parse.ts: when `onEvent` is set, the three fields are optional. When absent (interactive persona), they stay required. Validation that DOES run is preserved — a supplied `harness` is still enum-checked, a supplied `model`/ `systemPrompt` must still be non-empty. - types.ts: `harness?` / `model?` / `systemPrompt?` on PersonaSpec. PersonaSelection (interactive resolution) keeps them required. - schema regenerated: the three drop out of `required`. Consumers handled: - runtime/cloud-defaults: `ctx.harness.run()` now throws a pointed error naming the missing field(s) instead of passing undefined into the spec builder — this is the "errors clearly at runtime" contract for a handler that calls the harness without declaring one. - deploy/cloud launcher: skips harness-credential provisioning entirely when no harness is declared. - workload-router + cli buildSelection: guard that interactive/built-in personas have the fields (they always do), with a clear error otherwise. - cli list/show: render `—` instead of `undefined` for handler personas. Tests: persona-kit 221, deploy 121, runtime 50, workload-router 13 — all pass. 4 new persona-kit tests cover handler-omits-all, handler-bad-harness-still- rejected, and interactive-still-requires. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli.ts | 25 +++++-- packages/deploy/src/modes/cloud/index.ts | 16 ++++- .../persona-kit/schemas/persona.schema.json | 9 +-- packages/persona-kit/src/parse.test.ts | 68 +++++++++++++++++++ packages/persona-kit/src/parse.ts | 37 ++++++++-- packages/persona-kit/src/types.ts | 17 +++-- packages/runtime/src/cloud-defaults.ts | 26 ++++++- packages/workload-router/src/index.ts | 10 +++ 8 files changed, 179 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c7c3d73..646d739 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 @@ -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 @@ -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) { @@ -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'; } diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index 8df689d..c497533 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -278,6 +278,14 @@ async function ensureHarnessReady(args: { harnessSource?: HarnessSource; byokKey?: string; }): Promise> { + // 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') { @@ -716,8 +724,12 @@ 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'; @@ -725,7 +737,7 @@ function deriveModelProvider(persona: PersonaSpec): string { 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 { diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index e850734..ab5b1e8 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -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", @@ -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..triggers[]`), and inbox (RelayCast targeted messages, not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent.", diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 96b9ca7..3dff24a 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -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. diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 5d6b755..c3a9fd5 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -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( @@ -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 } : {}), diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 75edc53..4ec5b49 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -276,12 +276,17 @@ export interface PersonaSpec { * values are substituted into the persona's system prompt. */ inputs?: Record; - /** 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; /** diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index 77d3f1c..6d941c9 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -160,6 +160,26 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { env: NodeJS.ProcessEnv; }): (run: HarnessRunArgs) => Promise { 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 }; @@ -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({ @@ -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, diff --git a/packages/workload-router/src/index.ts b/packages/workload-router/src/index.ts index a152107..27629a7 100644 --- a/packages/workload-router/src/index.ts +++ b/packages/workload-router/src/index.ts @@ -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,