mastra-ai/mastra · error · FactoryRuleValidationError
Factory skill invocation needs exactly one of skillName or p
Error message
Factory skill invocation needs exactly one of skillName or prompt.
What it means
An 'invoke skill' factory decision must specify exactly one kickoff source: either skillName (run a named skill) or prompt (run freeform text). The check (skillName === undefined) === (prompt === undefined) throws when both are present or both are absent, because the dispatcher would otherwise have to pick a winner between two authoring forms of the same kickoff message.
Source
Thrown at mastracode/factory/src/rules/validation.ts:288
source: enumValue(value.source, WORK_ITEM_SOURCES, 'Factory linked work item source'),
sourceKey: boundedString(value.sourceKey, 'Factory linked work item sourceKey', MAX_SOURCE_KEY_LENGTH),
title: boundedString(value.title, 'Factory linked work item title', MAX_TITLE_LENGTH),
url,
stage: enumValue(value.stage, FACTORY_RULE_STAGES, 'Factory linked work item stage'),
...(metadata ? { metadata } : {}),
};
}
case 'invokeSkill': {
assertExactKeys(
value,
['type', 'idempotencyKey', 'role', 'skillName', 'prompt', 'arguments', 'precedingMessage', 'cancelInFlight'],
'Factory invoke skill decision',
);
// A run activates a skill or carries a prompt, never both: they are two
// ways to author the same kickoff message, so accepting both would leave
// the dispatcher picking a winner.
if ((value.skillName === undefined) === (value.prompt === undefined)) {
throw new FactoryRuleValidationError('Factory skill invocation needs exactly one of skillName or prompt.');
}
const args = optionalBoundedString(value.arguments, 'Factory skill arguments', MAX_ARGUMENTS_LENGTH);
const precedingMessage = optionalBoundedString(
value.precedingMessage,
'Factory skill preceding message',
MAX_MESSAGE_LENGTH,
);
if (value.cancelInFlight !== undefined && typeof value.cancelInFlight !== 'boolean') {
throw new FactoryRuleValidationError('Factory skill cancelInFlight must be a boolean.');
}
return {
type,
...commonCommitFields(value),
role: boundedString(value.role, 'Factory skill role', MAX_ROLE_LENGTH, IDENTIFIER_RE),
...(value.skillName === undefined
? { prompt: boundedString(value.prompt, 'Factory skill prompt', MAX_MESSAGE_LENGTH) }
: { skillName: boundedString(value.skillName, 'Factory skill name', MAX_SKILL_NAME_LENGTH, SKILL_NAME_RE) }),
...(args ? { arguments: args } : {}),View on GitHub (pinned to 75dd419e61)
Solutions
- Pick one authoring form: set skillName for skill-based runs, or prompt for ad-hoc text, and delete the other field.
- If both values are available, embed the prompt as the skill's arguments or precedingMessage instead of a top-level prompt.
- Ensure conditional construction always assigns exactly one of the two fields before returning the decision.
Example fix
// before
return { type, skillName: 'deploy', prompt: 'deploy to staging', ...commonCommitFields(value) }
// after
return { type, skillName: 'deploy', arguments: 'deploy to staging', ...commonCommitFields(value) } Defensive patterns
Strategy: validation
Validate before calling
function checkXor(d) {
const hasSkill = d.skillName !== undefined;
const hasPrompt = d.prompt !== undefined;
if (hasSkill === hasPrompt) throw new Error('invoke-skill decision needs exactly one of skillName or prompt');
} Type guard
function hasExactlyOneKickoff<T extends { skillName?: string; prompt?: string }>(
d: T,
): d is T & ({ skillName: string } | { prompt: string }) {
return (d.skillName === undefined) !== (d.prompt === undefined);
} Try / catch
try {
commitDecision(decision);
} catch (e) {
if (e instanceof FactoryRuleValidationError && /exactly one of skillName or prompt/.test(e.message)) {
decision = { ...decision, prompt: decision.prompt ?? decision.skillName };
delete decision.skillName;
return commitDecision(decision);
}
throw e;
} Prevention
- Model the decision type in TypeScript as a union so skillName and prompt can never coexist.
- Route prompt text into arguments/precedingMessage when a skillName is present.
- Add a unit test asserting exactly one field is set for every rule output.
When it happens
Trigger: Emitting a FactoryCommitDecision of the invoke-skill type with both skillName and prompt set; or with neither set (e.g. constructing the object conditionally so both end up undefined).
Common situations: Rule code that merges a default prompt with a configured skill name; templates where optional fields collapse to undefined leaving neither populated; copy-paste edits that add prompt alongside an existing skillName.
Related errors
- Factory rule version is required.
- ${label} contains an unsupported field.
- Rule metadata is too large.
- ${label} must be an object.
- ${label}.${stage} must be an object.
AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30).
Data as JSON: /api/errors/cd2cebd8845c5d6f.
Report an issue: GitHub.