{"record":{"id":"cd2cebd8845c5d6f","repo":"mastra-ai/mastra","slug":"factory-skill-invocation-needs-exactly-one-of-skil","errorCode":null,"errorMessage":"Factory skill invocation needs exactly one of skillName or prompt.","messagePattern":"Factory skill invocation needs exactly one of skillName or prompt\\.","errorType":"validation","errorClass":"FactoryRuleValidationError","httpStatus":null,"severity":"error","filePath":"mastracode/factory/src/rules/validation.ts","lineNumber":288,"sourceCode":"        source: enumValue(value.source, WORK_ITEM_SOURCES, 'Factory linked work item source'),\n        sourceKey: boundedString(value.sourceKey, 'Factory linked work item sourceKey', MAX_SOURCE_KEY_LENGTH),\n        title: boundedString(value.title, 'Factory linked work item title', MAX_TITLE_LENGTH),\n        url,\n        stage: enumValue(value.stage, FACTORY_RULE_STAGES, 'Factory linked work item stage'),\n        ...(metadata ? { metadata } : {}),\n      };\n    }\n    case 'invokeSkill': {\n      assertExactKeys(\n        value,\n        ['type', 'idempotencyKey', 'role', 'skillName', 'prompt', 'arguments', 'precedingMessage', 'cancelInFlight'],\n        'Factory invoke skill decision',\n      );\n      // A run activates a skill or carries a prompt, never both: they are two\n      // ways to author the same kickoff message, so accepting both would leave\n      // the dispatcher picking a winner.\n      if ((value.skillName === undefined) === (value.prompt === undefined)) {\n        throw new FactoryRuleValidationError('Factory skill invocation needs exactly one of skillName or prompt.');\n      }\n      const args = optionalBoundedString(value.arguments, 'Factory skill arguments', MAX_ARGUMENTS_LENGTH);\n      const precedingMessage = optionalBoundedString(\n        value.precedingMessage,\n        'Factory skill preceding message',\n        MAX_MESSAGE_LENGTH,\n      );\n      if (value.cancelInFlight !== undefined && typeof value.cancelInFlight !== 'boolean') {\n        throw new FactoryRuleValidationError('Factory skill cancelInFlight must be a boolean.');\n      }\n      return {\n        type,\n        ...commonCommitFields(value),\n        role: boundedString(value.role, 'Factory skill role', MAX_ROLE_LENGTH, IDENTIFIER_RE),\n        ...(value.skillName === undefined\n          ? { prompt: boundedString(value.prompt, 'Factory skill prompt', MAX_MESSAGE_LENGTH) }\n          : { skillName: boundedString(value.skillName, 'Factory skill name', MAX_SKILL_NAME_LENGTH, SKILL_NAME_RE) }),\n        ...(args ? { arguments: args } : {}),","sourceCodeStart":270,"sourceCodeEnd":306,"githubUrl":"https://github.com/mastra-ai/mastra/blob/75dd419e613fe9c39f846ffc500716141b74fda6/mastracode/factory/src/rules/validation.ts#L270-L306","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nreturn { type, skillName: 'deploy', prompt: 'deploy to staging', ...commonCommitFields(value) }\n// after\nreturn { type, skillName: 'deploy', arguments: 'deploy to staging', ...commonCommitFields(value) }","handlingStrategy":"validation","validationCode":"function checkXor(d) {\n  const hasSkill = d.skillName !== undefined;\n  const hasPrompt = d.prompt !== undefined;\n  if (hasSkill === hasPrompt) throw new Error('invoke-skill decision needs exactly one of skillName or prompt');\n}","typeGuard":"function hasExactlyOneKickoff<T extends { skillName?: string; prompt?: string }>(\n  d: T,\n): d is T & ({ skillName: string } | { prompt: string }) {\n  return (d.skillName === undefined) !== (d.prompt === undefined);\n}","tryCatchPattern":"try {\n  commitDecision(decision);\n} catch (e) {\n  if (e instanceof FactoryRuleValidationError && /exactly one of skillName or prompt/.test(e.message)) {\n    decision = { ...decision, prompt: decision.prompt ?? decision.skillName };\n    delete decision.skillName;\n    return commitDecision(decision);\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["validation","factory-rules","xor-fields"],"backgroundTag":"mutually-exclusive-fields-required","analyzedSha":"75dd419e613fe9c39f846ffc500716141b74fda6","analyzedAt":"2026-08-30T00:15:31.844Z","schemaVersion":2},"datasetVersion":"2026-08-30T03:17:51.788Z"}