mastra-ai/mastra · error · Error

${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" can

Error message

${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.

What it means

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.

Source

Thrown at packages/core/src/workflows/dynamic/serialize.ts:221

  entryId: string,
  kind: 'agent' | 'tool',
): SerializedStepOptions | undefined {
  if (!options || typeof options !== 'object') return undefined;

  // Closure-valued options don't round-trip. Fail loudly at serialize time so
  // the workflow author immediately learns their step won't persist rather
  // than discovering it in production when the callback silently no-ops.
  const forbidden: Array<{ key: string; hint: string }> = [
    { key: 'onFinish', hint: 'callback closure' },
    { key: 'onChunk', hint: 'callback closure' },
    { key: 'onError', hint: 'callback closure' },
    { key: 'onStepFinish', hint: 'callback closure' },
    { key: 'onAbort', hint: 'callback closure' },
    { key: 'toolChoice', hint: 'may be a function' },
  ];
  for (const { key, hint } of forbidden) {
    if (typeof options[key] === 'function') {
      throw new Error(
        `${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.`,
      );
    }
  }
  if (typeof options.scorers === 'function') {
    throw new Error(
      `${kind === 'agent' ? 'Agent' : 'Tool'} step "${entryId}" cannot be stored: "scorers" is a function; only the static array form round-trips.`,
    );
  }

  const out: SerializedStepOptions = {};
  if (typeof options.retries === 'number') out.retries = options.retries;
  if (options.metadata && typeof options.metadata === 'object') {
    out.metadata = options.metadata as Record<string, any>;
  }
  return Object.keys(out).length > 0 ? out : undefined;
}

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Remove the function-valued option from the step's options before persisting.
  2. Move callback logic outside the persisted workflow: run it in the caller via run-level events, or place it in a wrapper plain step.
  3. Replace function `toolChoice` with the static form (a named tool or fixed choice).
  4. Pre-validate: `['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof options[k] !== 'function')`.

Example fix

// before
agent('myAgent', { onFinish: (res) => log(res), retries: 2 })

// after
agent('myAgent', { retries: 2 }) // log via run-level onFinish in the host app
Defensive patterns

Strategy: validation

Validate before calling

const FORBIDDEN = ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'];
for (const e of stepFlow) {
  if (e.type === 'agent' || e.type === 'tool') {
    for (const k of FORBIDDEN) {
      if (e.options && typeof e.options[k] === 'function') throw new Error(`${e.type} "${e.id}" option "${k}" is a function`);
    }
  }
}

Type guard

const hasSerializableOptions = (e: StepFlowEntry): boolean =>
  !(e.type === 'agent' || e.type === 'tool') ||
  !e.options ||
  ['onFinish','onChunk','onError','onStepFinish','onAbort','toolChoice'].every(k => typeof (e.options as any)[k] !== 'function');

Try / catch

try {
  storable = toStorableGraph(stepFlow);
} catch (e) {
  if (/option ".*" is a .* that does not round-trip/.test(e.message)) {
    // strip or relocate the callback named in the message, then retry
  } else throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


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