{"record":{"id":"c4fac9447dc7ba21","repo":"mastra-ai/mastra","slug":"kind-agent-agent-tool-step-ent","errorCode":null,"errorMessage":"${kind === 'agent' ? 'Agent' : 'Tool'} step \"${entryId}\" cannot be stored: option \"${key}\" is a ${hint} that does not round-trip. Remove it or move that logic outside the persisted workflow.","messagePattern":"(.+?) step \"(.+?)\" cannot be stored: option \"(.+?)\" is a (.+?) that does not round-trip\\. Remove it or move that logic outside the persisted workflow\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/core/src/workflows/dynamic/serialize.ts","lineNumber":221,"sourceCode":"  entryId: string,\n  kind: 'agent' | 'tool',\n): SerializedStepOptions | undefined {\n  if (!options || typeof options !== 'object') return undefined;\n\n  // Closure-valued options don't round-trip. Fail loudly at serialize time so\n  // the workflow author immediately learns their step won't persist rather\n  // than discovering it in production when the callback silently no-ops.\n  const forbidden: Array<{ key: string; hint: string }> = [\n    { key: 'onFinish', hint: 'callback closure' },\n    { key: 'onChunk', hint: 'callback closure' },\n    { key: 'onError', hint: 'callback closure' },\n    { key: 'onStepFinish', hint: 'callback closure' },\n    { key: 'onAbort', hint: 'callback closure' },\n    { key: 'toolChoice', hint: 'may be a function' },\n  ];\n  for (const { key, hint } of forbidden) {\n    if (typeof options[key] === 'function') {\n      throw new Error(\n        `${kind === 'agent' ? 'Agent' : 'Tool'} step \"${entryId}\" cannot be stored: option \"${key}\" is a ${hint} that does not round-trip. Remove it or move that logic outside the persisted workflow.`,\n      );\n    }\n  }\n  if (typeof options.scorers === 'function') {\n    throw new Error(\n      `${kind === 'agent' ? 'Agent' : 'Tool'} step \"${entryId}\" cannot be stored: \"scorers\" is a function; only the static array form round-trips.`,\n    );\n  }\n\n  const out: SerializedStepOptions = {};\n  if (typeof options.retries === 'number') out.retries = options.retries;\n  if (options.metadata && typeof options.metadata === 'object') {\n    out.metadata = options.metadata as Record<string, any>;\n  }\n  return Object.keys(out).length > 0 ? out : undefined;\n}\n","sourceCodeStart":203,"sourceCodeEnd":239,"githubUrl":"https://github.com/mastra-ai/mastra/blob/75dd419e613fe9c39f846ffc500716141b74fda6/packages/core/src/workflows/dynamic/serialize.ts#L203-L239","documentation":"Agent and tool step entries carry an options bag; only JSON-safe options (`retries`, `metadata`) round-trip through storage. If any of the forbidden options — `onFinish`, `onChunk`, `onError`, `onStepFinish`, `onAbort`, or a function-valued `toolChoice` — is a function (callback closure), `pickSerializableStepOptions` throws, because the callback would silently disappear after save/reload.","triggerScenarios":"Persisting a workflow whose `.agent(...)` or `.tool(...)` entry options include streaming/lifecycle callbacks (e.g. `onFinish: (result) => ...`, `onStepFinish`, `onChunk`, `onError`, `onAbort`) or `toolChoice` given as a function.","commonSituations":"Authors copy agent-generation options (callbacks for streaming UIs, per-step logging, abort handling) into workflow steps; those callbacks work live but break persistence. Common when reusing the same options object for direct generation and workflow steps.","solutions":["Remove the function-valued option from the step's options before persisting.","Move callback logic outside the persisted workflow: run it in the caller via run-level events, or place it in a wrapper plain step.","Replace function `toolChoice` with the static form (a named tool or fixed choice).","Pre-validate: `['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof options[k] !== 'function')`."],"exampleFix":"// before\nagent('myAgent', { onFinish: (res) => log(res), retries: 2 })\n\n// after\nagent('myAgent', { retries: 2 }) // log via run-level onFinish in the host app","handlingStrategy":"validation","validationCode":"const FORBIDDEN = ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'];\nfor (const e of stepFlow) {\n  if (e.type === 'agent' || e.type === 'tool') {\n    for (const k of FORBIDDEN) {\n      if (e.options && typeof e.options[k] === 'function') throw new Error(`${e.type} \"${e.id}\" option \"${k}\" is a function`);\n    }\n  }\n}","typeGuard":"const hasSerializableOptions = (e: StepFlowEntry): boolean =>\n  !(e.type === 'agent' || e.type === 'tool') ||\n  !e.options ||\n  ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof (e.options as any)[k] !== 'function');","tryCatchPattern":"try {\n  storable = toStorableGraph(stepFlow);\n} catch (e) {\n  if (/option \".*\" is a .* that does not round-trip/.test(e.message)) {\n    // strip or relocate the callback named in the message, then retry\n  } else throw e;\n}","preventionTips":["Keep persisted step options limited to retries and metadata.","Handle streaming/lifecycle callbacks at run level in the host app, not in step options.","Share a validated options builder for agent/tool steps so closures never sneak in."],"tags":["workflows","serialization","closure","callbacks"],"backgroundTag":"non-serializable-closure","analyzedSha":"75dd419e613fe9c39f846ffc500716141b74fda6","analyzedAt":"2026-08-30T00:15:31.844Z","schemaVersion":2},"datasetVersion":"2026-08-30T03:17:51.788Z"}