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

  1. Pick one authoring form: set skillName for skill-based runs, or prompt for ad-hoc text, and delete the other field.
  2. If both values are available, embed the prompt as the skill's arguments or precedingMessage instead of a top-level prompt.
  3. 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

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


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/cd2cebd8845c5d6f. Report an issue: GitHub.