@mastra/core
Version:
1 lines • 71.4 kB
Source Map (JSON)
{"version":3,"file":"tools-B-JXNZhs.cjs","names":["SandboxFeatureNotSupportedError","z","createTool","isValidationError","z","createTool","createTool","z","MastraError","ErrorDomain","ErrorCategory","z","createTool"],"sources":["../src/tools/code-mode/stub-generator.ts","../src/tools/code-mode/runner.ts","../src/tools/code-mode/transport.ts","../src/tools/code-mode/code-mode.ts","../src/tools/builtin/ask-user.ts","../src/tools/builtin/web-fetch.ts","../src/tools/builtin/web-search.ts","../src/tools/builtin/submit-plan.ts"],"sourcesContent":["/**\n * Code Mode — Type stub generation\n *\n * Converts Mastra tools into TypeScript `declare function external_<id>(...)`\n * stubs and assembles the instructions the model sees. The pipeline is:\n *\n * tool.inputSchema (StandardSchemaWithJSON)\n * -> standardSchemaToJSONSchema() (already in core, zod v3 + v4 + arktype)\n * -> jsonSchemaToTsString() (this file, synchronous, dependency-free)\n * -> stub string\n *\n * Only the subset of JSON Schema that tool schemas actually produce is handled;\n * anything else degrades to `unknown`.\n */\n\nimport type { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from 'json-schema';\nimport type { ToolsInput } from '../../agent/types';\nimport { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '../../schema';\nimport type { StandardSchemaWithJSON } from '../../schema';\nimport type { CodeModeConfig } from './types';\n\n/** A valid TypeScript identifier? (used to decide quoting of object keys). */\nconst SAFE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n\n/**\n * Convert a JSON Schema (draft-07) node into a TypeScript type string.\n * Unsupported constructs return `unknown`.\n */\nexport function jsonSchemaToTsString(schema: JSONSchema7Definition | undefined): string {\n if (schema === undefined) return 'unknown';\n if (typeof schema === 'boolean') return schema ? 'unknown' : 'never';\n\n // enum / const\n if (schema.const !== undefined) return literal(schema.const);\n if (Array.isArray(schema.enum)) {\n return schema.enum.length ? schema.enum.map(literal).join(' | ') : 'never';\n }\n\n // unions\n const union = schema.anyOf ?? schema.oneOf;\n if (Array.isArray(union) && union.length) {\n return union.map(jsonSchemaToTsString).join(' | ');\n }\n\n const type = normalizeType(schema.type);\n\n if (type === 'object' || schema.properties) {\n return objectType(schema);\n }\n if (type === 'array' || schema.items) {\n return arrayType(schema);\n }\n\n switch (type) {\n case 'string':\n return 'string';\n case 'number':\n case 'integer':\n return 'number';\n case 'boolean':\n return 'boolean';\n case 'null':\n return 'null';\n default:\n return 'unknown';\n }\n}\n\nfunction normalizeType(type: JSONSchema7['type']): JSONSchema7TypeName | undefined {\n if (Array.isArray(type)) {\n // e.g. ['string', 'null'] — caller folds null in via nullability; pick the\n // first non-null for the base type.\n return type.find(t => t !== 'null');\n }\n return type;\n}\n\nfunction objectType(schema: JSONSchema7): string {\n const props = schema.properties ?? {};\n const required = new Set(schema.required ?? []);\n const keys = Object.keys(props);\n\n if (!keys.length) {\n // Free-form object.\n const additional = schema.additionalProperties;\n if (additional !== undefined && additional !== false) {\n const valueType = typeof additional === 'object' ? jsonSchemaToTsString(additional) : 'unknown';\n return `Record<string, ${valueType}>`;\n }\n return 'Record<string, unknown>';\n }\n\n const fields = keys.map(key => {\n const optional = !required.has(key) ? '?' : '';\n const k = SAFE_IDENT.test(key) ? key : JSON.stringify(key);\n return `${k}${optional}: ${jsonSchemaToTsString(props[key])}`;\n });\n return `{ ${fields.join('; ')} }`;\n}\n\nfunction arrayType(schema: JSONSchema7): string {\n const items = schema.items;\n if (Array.isArray(items)) {\n // Tuple.\n return `[${items.map(jsonSchemaToTsString).join(', ')}]`;\n }\n const inner = jsonSchemaToTsString(items);\n // Use `Array<...>` form for top-level unions so `A | B[]` isn't misread as\n // `A | (B[])`. Object literals and other forms use the `T[]` shorthand.\n return isTopLevelUnion(inner) ? `Array<${inner}>` : `${inner}[]`;\n}\n\n/** True if `ts` is a union at the top level (a ` | ` not nested in braces/brackets). */\nfunction isTopLevelUnion(ts: string): boolean {\n let depth = 0;\n for (let i = 0; i < ts.length; i++) {\n const c = ts[i];\n if (c === '{' || c === '[' || c === '(' || c === '<') depth++;\n else if (c === '}' || c === ']' || c === ')' || c === '>') depth--;\n else if (c === '|' && depth === 0) return true;\n }\n return false;\n}\n\nfunction literal(value: unknown): string {\n if (typeof value === 'string') return JSON.stringify(value);\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (value === null) return 'null';\n return 'unknown';\n}\n\nfunction schemaToTs(schema: unknown, io: 'input' | 'output'): string {\n if (!isStandardSchemaWithJSON(schema)) return 'unknown';\n try {\n const json = standardSchemaToJSONSchema(schema as StandardSchemaWithJSON, { io });\n return jsonSchemaToTsString(json as JSONSchema7);\n } catch {\n return 'unknown';\n }\n}\n\n/**\n * Strip non-identifier characters so a tool id is a legal function-name suffix.\n *\n * Transports that map `external_*` names back to tool ids must use this same\n * sanitizer so their naming stays identical to the generated stubs.\n */\nexport function sanitizeToolId(id: string): string {\n const cleaned = id.replace(/[^A-Za-z0-9_$]/g, '_');\n return SAFE_IDENT.test(cleaned) ? cleaned : `_${cleaned}`;\n}\n\n/** A single tool's TS declaration plus the original/sanitized id mapping. */\nexport interface CodeModeStub {\n /** Original tool id (key used by the RPC dispatcher). */\n toolId: string;\n /** Sanitized identifier used in `external_<name>`. */\n externalName: string;\n /** The full `declare function ...` line(s). */\n declaration: string;\n}\n\n/** Generate stubs for every tool in the config. */\nexport function generateStubs(tools: ToolsInput): CodeModeStub[] {\n // Two distinct tool ids can sanitize to the same `external_*` name (e.g.\n // `a-b` and `a_b`). Without this check the later binding would silently\n // overwrite the earlier one in the runner, so fail fast instead.\n const seen = new Map<string, string>();\n return Object.entries(tools).map(([key, tool]) => {\n const toolId = (tool as { id?: string }).id ?? key;\n const description = (tool as { description?: string }).description;\n const inputType = schemaToTs((tool as { inputSchema?: unknown }).inputSchema, 'input');\n const outputType = schemaToTs((tool as { outputSchema?: unknown }).outputSchema, 'output');\n const externalName = sanitizeToolId(toolId);\n\n const prior = seen.get(externalName);\n if (prior !== undefined && prior !== toolId) {\n throw new Error(`Code Mode tool id collision: \"${prior}\" and \"${toolId}\" both map to external_${externalName}`);\n }\n seen.set(externalName, toolId);\n\n const doc = description ? `/** ${description.replace(/\\*\\//g, '* /')} */\\n` : '';\n const declaration = `${doc}declare function external_${externalName}(input: ${inputType}): Promise<${outputType}>;`;\n\n return { toolId, externalName, declaration };\n });\n}\n\nconst USAGE_CONTRACT = `# Code Mode\n\nYou have an \\`execute_typescript\\` tool. Instead of calling tools one at a time,\nwrite a single TypeScript program that orchestrates them and returns one result.\n\nRules:\n- Call the available tools via the \\`external_*\\` functions declared below. Each\n returns a Promise — \\`await\\` it.\n- Batch independent calls with \\`Promise.all\\`. Do arithmetic and data shaping in\n JavaScript, not in your head.\n- End the program by \\`return\\`-ing the final value (objects/arrays are fine).\n- The only supported capabilities are the \\`external_*\\` functions. Do not rely\n on filesystem, network, or process access — depending on the configured\n sandbox and transport, the program may run fully isolated with none of those\n available.\n- Use \\`console.log\\` for debugging; logs are captured and returned.\n\nAvailable functions:`;\n\n/** Build the full instructions string (usage contract + stubs). */\nexport function createCodeModeInstructions(config: CodeModeConfig): string {\n const stubs = generateStubs(config.tools);\n const declarations = stubs.map(s => s.declaration).join('\\n\\n');\n return `${USAGE_CONTRACT}\\n\\n${declarations}`;\n}\n","/**\n * Code Mode — Sandbox runner\n *\n * Builds the JavaScript program that runs *inside* the sandbox. The runner:\n * - defines an `external_<name>` function per allow-listed tool, each of which\n * emits a JSON-RPC request on the protocol channel and awaits its response\n * (matched by `id`, so `Promise.all` calls resolve independently);\n * - wraps the model's program in an async function, captures `console.*`, and\n * emits a terminal `done` frame.\n *\n * Protocol (host <-> runner), newline-delimited JSON on stdout/stdin:\n * - Frames the runner emits are prefixed with FRAME_PREFIX so the host can\n * tell them apart from any stray output. Forms: `rpc`, `log`, `done`.\n * - The host writes `rpc-result` frames to the runner stdin (no prefix).\n */\n\n/** Marks a line on stdout as a Code Mode protocol frame. */\nexport const FRAME_PREFIX = '\\u0000CODEMODE\\u0000';\n\nexport interface BuildRunnerOptions {\n /**\n * Module specifier the runner imports to obtain the user program. The\n * referenced module must `export default` an async function (the wrapped\n * model code). Written as a sibling `.ts` file so the sandbox's `node`\n * strips the TypeScript types natively at import time.\n */\n programModule: string;\n /** Map of `external_<name>` -> original tool id used in the RPC request. */\n externals: Array<{ externalName: string; toolId: string }>;\n}\n\n/**\n * Wrap the model's TypeScript program as a default-exported async function\n * module. Written to a `.ts` file; Node strips the type annotations at import.\n * Top-level `return`, `await`, and `const` work because the body lives inside\n * an async function.\n */\nexport function buildProgramModule(program: string): string {\n return `export default async function () {\\n${program}\\n}\\n`;\n}\n\n/**\n * Produce the full runner source to write into the sandbox and run with node.\n */\nexport function buildRunner({ programModule, externals }: BuildRunnerOptions): string {\n // `buildRunner` is exported, so a caller could pass a non-sanitized name.\n // External names become global property suffixes, so reject anything that\n // isn't a legal identifier instead of producing an unusable global.\n const SAFE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;\n const seen = new Map<string, string>();\n for (const { externalName, toolId } of externals) {\n if (!SAFE_IDENT.test(externalName)) {\n throw new Error(`Invalid Code Mode external identifier: ${externalName}`);\n }\n // Two tool ids can sanitize to the same external name (e.g. `a-b` and\n // `a_b` both become `a_b`). The install loop below would silently overwrite\n // the earlier global, leaving one tool unreachable. Fail fast instead.\n const existing = seen.get(externalName);\n if (existing) {\n throw new Error(\n `Code Mode external identifier collision: tools \"${existing}\" and \"${toolId}\" both map to external_${externalName}`,\n );\n }\n seen.set(externalName, toolId);\n }\n\n // Externals are emitted as JSON data, not interpolated identifiers. The\n // runner installs each `external_<name>` global in a loop using bracket\n // assignment, so no caller-derived string is ever spliced into the generated\n // source as code. This keeps tool ids strictly data, even if `sanitize`\n // changes.\n const externalsJson = JSON.stringify(externals.map(({ externalName, toolId }) => ({ externalName, toolId })));\n\n return `'use strict';\nconst FRAME_PREFIX = ${JSON.stringify(FRAME_PREFIX)};\n\nfunction __emit(frame) {\n process.stdout.write(FRAME_PREFIX + JSON.stringify(frame) + '\\\\n');\n}\n\nfunction __emitDoneAndExit(frame) {\n process.stdout.write(FRAME_PREFIX + JSON.stringify(frame) + '\\\\n', () => process.exit(0));\n}\n\n// ---- console capture -------------------------------------------------------\nfor (const level of ['log', 'info', 'warn', 'error']) {\n console[level] = (...args) => {\n const message = args\n .map((a) => (typeof a === 'string' ? a : safeStringify(a)))\n .join(' ');\n __emit({ type: 'log', level, message });\n };\n}\nfunction safeStringify(value) {\n try { return JSON.stringify(value); } catch { return String(value); }\n}\n\n// ---- RPC bridge ------------------------------------------------------------\nlet __nextId = 0;\nconst __pending = new Map();\n\nfunction __rpc(tool, args) {\n const id = __nextId++;\n return new Promise((resolve, reject) => {\n __pending.set(id, { resolve, reject });\n __emit({ type: 'rpc', id, tool, args });\n });\n}\n\nlet __stdinBuffer = '';\nprocess.stdin.setEncoding('utf8');\nprocess.stdin.on('data', (chunk) => {\n __stdinBuffer += chunk;\n let idx;\n while ((idx = __stdinBuffer.indexOf('\\\\n')) >= 0) {\n const line = __stdinBuffer.slice(0, idx);\n __stdinBuffer = __stdinBuffer.slice(idx + 1);\n if (!line) continue;\n let frame;\n try { frame = JSON.parse(line); } catch { continue; }\n if (frame && frame.type === 'rpc-result') {\n const entry = __pending.get(frame.id);\n if (!entry) continue;\n __pending.delete(frame.id);\n if (frame.ok) entry.resolve(frame.result);\n else {\n const err = new Error(frame.error?.message || 'external tool failed');\n if (frame.error?.name) err.name = frame.error.name;\n entry.reject(err);\n }\n }\n }\n});\n\n// ---- externals -------------------------------------------------------------\nfor (const { externalName, toolId } of ${externalsJson}) {\n globalThis['external_' + externalName] = (input) => __rpc(toolId, input);\n}\n\n// ---- user program ----------------------------------------------------------\n// The program lives in a sibling .ts module exporting a default async function;\n// node strips its TypeScript types natively on import.\nasync function __main() {\n const mod = await import(${JSON.stringify(programModule)});\n return await mod.default();\n}\n\n__main()\n .then((result) => {\n __emitDoneAndExit({ type: 'done', ok: true, result });\n })\n .catch((error) => {\n __emitDoneAndExit({\n type: 'done',\n ok: false,\n error: { message: error?.message ?? String(error), name: error?.name },\n });\n });\n`;\n}\n","/**\n * Code Mode — stdio JSON-RPC transport (v1)\n *\n * Runs the runner inside the sandbox via `sandbox.processes.spawn`, parses\n * protocol frames off stdout, dispatches `external_*` calls back to the host,\n * and writes results to the runner stdin. Abstracted behind\n * {@link CodeModeTransport} so socket/file-queue transports can be added for\n * remote sandboxes later.\n */\n\nimport { randomBytes } from 'node:crypto';\nimport { mkdtemp, rm, writeFile } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\n\nimport { SandboxFeatureNotSupportedError } from '../../workspace/errors';\nimport { buildRunner, buildProgramModule, FRAME_PREFIX } from './runner';\nimport { sanitizeToolId } from './stub-generator';\nimport type { CodeModeRunnerFrame, CodeModeToolResult, CodeModeTransport } from './types';\n\n/**\n * Default transport: writes the runner to a temp dir, spawns\n * `node <runner>`, and bridges RPC over stdio.\n */\nexport class StdioCodeModeTransport implements CodeModeTransport {\n async run(opts: Parameters<CodeModeTransport['run']>[0]): Promise<CodeModeToolResult> {\n const { sandbox, program, toolIds, dispatch, timeout, abortSignal, onExternalCall, onExternalResult } = opts;\n\n if (!sandbox) {\n throw new Error('StdioCodeModeTransport requires a sandbox');\n }\n if (!sandbox.processes) {\n throw new SandboxFeatureNotSupportedError('processes');\n }\n\n const externals = toolIds.map(toolId => ({ toolId, externalName: sanitizeToolId(toolId) }));\n const allowList = new Set(toolIds);\n\n const dir = await mkdtemp(join(tmpdir(), 'mastra-code-mode-'));\n const suffix = randomBytes(4).toString('hex');\n // The model's TypeScript program is written to its own .ts module; node\n // strips the type annotations when the runner imports it (see the\n // --experimental-strip-types flag on the spawn below).\n const programPath = join(dir, `program-${suffix}.ts`);\n await writeFile(programPath, buildProgramModule(program), 'utf8');\n const runnerSource = buildRunner({ programModule: pathToFileURL(programPath).href, externals });\n const runnerPath = join(dir, `runner-${suffix}.mjs`);\n await writeFile(runnerPath, runnerSource, 'utf8');\n\n const logs: string[] = [];\n let done: CodeModeToolResult | undefined;\n let stdoutBuffer = '';\n\n // Resolved once a terminal `done` frame arrives.\n let resolveDone!: () => void;\n const donePromise = new Promise<void>(resolve => {\n resolveDone = resolve;\n });\n\n try {\n // `--experimental-strip-types` lets node import the program's `.ts`\n // module on Node 22.6–22.17 (where type-stripping is still flagged). On\n // Node 22.18+/24, where stripping is the default, the flag is accepted as\n // a harmless no-op, so this works across the versions CI and users run.\n const handle = await sandbox.processes.spawn(`node --experimental-strip-types ${runnerPath}`, {\n cwd: dir,\n abortSignal,\n onStdout: (chunk: string) => {\n stdoutBuffer += chunk;\n let idx: number;\n while ((idx = stdoutBuffer.indexOf('\\n')) >= 0) {\n const line = stdoutBuffer.slice(0, idx);\n stdoutBuffer = stdoutBuffer.slice(idx + 1);\n if (!line.startsWith(FRAME_PREFIX)) continue;\n let frame: CodeModeRunnerFrame;\n try {\n frame = JSON.parse(line.slice(FRAME_PREFIX.length));\n } catch {\n continue;\n }\n handleFrame(frame);\n }\n },\n });\n\n function handleFrame(frame: CodeModeRunnerFrame): void {\n switch (frame.type) {\n case 'log':\n logs.push(frame.message);\n return;\n case 'done':\n done = frame.ok\n ? { success: true, result: frame.result, logs }\n : { success: false, error: frame.error, logs };\n resolveDone();\n return;\n case 'rpc':\n // `serveRpc` awaits `respond`, which writes to the child's stdin and\n // can reject if the process already exited/was killed. Swallow that\n // so it never surfaces as an unhandled rejection.\n void serveRpc(frame.id, frame.tool, frame.args).catch(() => {});\n return;\n }\n }\n\n // Observer hooks are caller-supplied and best-effort: a throwing hook must\n // never prevent `respond()` from running, or the matching in-sandbox promise\n // would hang until the timeout.\n function notifyCall(tool: string, args: unknown): void {\n try {\n onExternalCall?.(tool, args);\n } catch {\n /* observer errors are non-fatal */\n }\n }\n function notifyResult(tool: string, durationMs: number, error?: Error): void {\n try {\n onExternalResult?.(tool, durationMs, error);\n } catch {\n /* observer errors are non-fatal */\n }\n }\n\n async function serveRpc(id: number, tool: string, args: unknown): Promise<void> {\n const started = Date.now();\n notifyCall(tool, args);\n // Allow-list enforcement: never invoke a tool that wasn't exposed.\n if (!allowList.has(tool)) {\n notifyResult(tool, Date.now() - started, new Error('not allowed'));\n await respond(id, false, undefined, {\n message: `Tool \"${tool}\" is not available in Code Mode`,\n name: 'NotAllowedError',\n });\n return;\n }\n try {\n const result = await dispatch(tool, args);\n notifyResult(tool, Date.now() - started);\n await respond(id, true, result);\n } catch (error: any) {\n notifyResult(tool, Date.now() - started, error);\n await respond(id, false, undefined, {\n message: error?.message ?? String(error),\n name: error?.name,\n });\n }\n }\n\n async function respond(\n id: number,\n ok: boolean,\n result?: unknown,\n error?: { message: string; name?: string },\n ): Promise<void> {\n await handle.sendStdin(JSON.stringify({ type: 'rpc-result', id, ok, result, error }) + '\\n');\n }\n\n // Race completion against process exit and the timeout. Including process\n // exit means a runner that dies without emitting `done` resolves\n // immediately instead of waiting out the full timeout.\n let timer: NodeJS.Timeout | undefined;\n const timeoutPromise = new Promise<'timeout'>(resolve => {\n timer = setTimeout(() => resolve('timeout'), timeout);\n });\n const exitPromise = handle.wait().then(() => 'exited' as const);\n\n const outcome = await Promise.race([\n donePromise.then(() => 'done' as const),\n exitPromise.catch(() => 'exited' as const),\n timeoutPromise,\n ]);\n if (timer) clearTimeout(timer);\n\n if (outcome === 'timeout') {\n await handle.kill().catch(() => {});\n return {\n success: false,\n logs,\n error: { message: `Code Mode execution timed out after ${timeout}ms`, name: 'TimeoutError' },\n };\n }\n\n // Either `done` arrived or the process exited. If we raced ahead of a\n // `done` frame still in flight, give it a brief beat to land.\n if (!done) {\n await exitPromise.catch(() => {});\n }\n\n return (\n done ?? {\n success: false,\n logs,\n error: { message: 'Program exited without returning a result', name: 'NoResultError' },\n }\n );\n } finally {\n await rm(dir, { recursive: true, force: true }).catch(() => {});\n }\n }\n}\n","/**\n * Code Mode — tool factory\n *\n * `createCodeMode(config)` returns the `execute_typescript` tool plus the\n * generated `instructions`. The tool transpiles the model's TypeScript to JS,\n * runs it in a WorkspaceSandbox via the transport, and bridges each\n * `external_*` call back to the real Mastra tool on the host.\n */\n\nimport { z } from 'zod/v4';\nimport type { WorkspaceSandbox } from '../../workspace/sandbox/sandbox';\nimport { createTool } from '../tool';\nimport type { Tool } from '../tool';\nimport { isValidationError } from '../validation';\nimport { createCodeModeInstructions } from './stub-generator';\nimport { StdioCodeModeTransport } from './transport';\nimport type { CodeModeConfig, CodeModeToolDispatcher, CodeModeToolResult, CodeModeTransport } from './types';\n\nconst DEFAULT_TIMEOUT = 30_000;\nconst DEFAULT_TOOL_NAME = 'execute_typescript';\n\nconst codeModeInputSchema = z.object({\n code: z\n .string()\n .describe(\n 'A TypeScript program that orchestrates the available external_* tools and returns a final value. ' +\n 'Use Promise.all to batch calls; do arithmetic in JS. End with `return <value>`.',\n ),\n});\n\nconst codeModeOutputSchema = z.object({\n success: z.boolean(),\n result: z.unknown().optional(),\n logs: z.array(z.string()).optional(),\n error: z\n .object({\n message: z.string(),\n name: z.string().optional(),\n line: z.number().optional(),\n })\n .optional(),\n});\n\n/** Result of {@link createCodeMode}: the tool plus its generated instructions. */\nexport interface CodeModeResult {\n tool: Tool<any, any>;\n instructions: string;\n}\n\n/** Resolve the tool key -> tool map keyed by the tool's effective id. */\nfunction indexToolsById(config: CodeModeConfig): Map<string, { execute?: (args: any, ctx: any) => Promise<any> }> {\n const map = new Map();\n for (const [key, tool] of Object.entries(config.tools)) {\n const id = (tool as { id?: string }).id ?? key;\n map.set(id, tool);\n }\n return map;\n}\n\n/**\n * Create only the `execute_typescript` tool. Most callers want\n * {@link createCodeMode}, which also returns the matching instructions.\n */\nexport function createCodeModeTool(\n config: CodeModeConfig,\n transport: CodeModeTransport = new StdioCodeModeTransport(),\n) {\n const timeout = config.timeout ?? DEFAULT_TIMEOUT;\n const id = config.id ?? DEFAULT_TOOL_NAME;\n const toolsById = indexToolsById(config);\n const toolIds = [...toolsById.keys()];\n\n return createTool({\n id,\n description:\n 'Execute a TypeScript program that orchestrates the available tools in a sandbox. ' +\n 'Prefer this over calling tools one at a time when a task needs multiple tool calls, ' +\n 'batching, aggregation, or arithmetic.',\n inputSchema: codeModeInputSchema,\n outputSchema: codeModeOutputSchema,\n execute: async ({ code }, ctx): Promise<CodeModeToolResult> => {\n // Resolve sandbox: explicit config -> workspace from context. There is no\n // implicit fallback: Code Mode runs model-authored code, so the execution\n // boundary must be chosen deliberately. To run locally (host privileges),\n // pass `sandbox: new LocalSandbox()` explicitly. Transports that provide\n // their own execution boundary (e.g. in-process V8 isolates) declare\n // `requiresSandbox: false` and run without one.\n const sandbox: WorkspaceSandbox | undefined = config.sandbox ?? ctx?.workspace?.sandbox;\n if (!sandbox && transport.requiresSandbox !== false) {\n throw new Error(\n 'Code Mode requires a sandbox to run model-authored code, but none was configured. ' +\n 'Pass one to createCodeMode({ tools, sandbox }), or run the agent in a workspace that provides a sandbox. ' +\n 'To execute on the host (host privileges — only for trusted/local use), pass `sandbox: new LocalSandbox()`.',\n );\n }\n\n // Each external_* call re-enters the real Mastra tool pipeline (validation,\n // request-context checks, tracing) on the host, with the outer tool's context.\n const dispatch: CodeModeToolDispatcher = async (toolId, args) => {\n const tool = toolsById.get(toolId);\n if (!tool?.execute) {\n throw new Error(`Tool \"${toolId}\" is not available in Code Mode`);\n }\n const result = await tool.execute(args, {\n mastra: ctx?.mastra,\n requestContext: ctx?.requestContext,\n abortSignal: ctx?.abortSignal,\n workspace: ctx?.workspace,\n });\n if (isValidationError(result)) {\n throw new Error(result.message ?? `Invalid input for tool \"${toolId}\"`);\n }\n return result;\n };\n\n // The TypeScript program is written to a .ts module by the transport;\n // the sandbox's node strips the type annotations natively at import.\n return ctx.observe.span(`code-mode:${id}`, () =>\n transport.run({\n sandbox,\n program: code,\n toolIds,\n dispatch,\n timeout,\n abortSignal: ctx?.abortSignal,\n onExternalCall: (tool, args) => ctx.observe.log('info', 'code-mode external call', { tool, args }),\n onExternalResult: (tool, durationMs, error) =>\n ctx.observe.log(error ? 'error' : 'info', 'code-mode external result', { tool, durationMs }),\n }),\n );\n },\n }) as unknown as Tool<any, any>;\n}\n\n/**\n * Create Code Mode: the `execute_typescript` tool plus generated instructions.\n *\n * @example\n * ```ts\n * const { tool, instructions } = createCodeMode({ tools: { getTopProducts, getProductRatings } });\n * const agent = new Agent({ instructions: ['You are helpful.', instructions], tools: { [tool.id]: tool } });\n * ```\n */\nexport function createCodeMode(config: CodeModeConfig, transport?: CodeModeTransport): CodeModeResult {\n return {\n tool: createCodeModeTool(config, transport),\n instructions: createCodeModeInstructions(config),\n };\n}\n","import { z } from 'zod/v4';\n\nimport { createTool } from '../tool';\n\n/**\n * A structured choice rendered by the host for an `ask_user` prompt.\n *\n * The label is the value returned to the model when the option is selected. The\n * optional description gives the host more context without changing the answer value.\n */\nexport interface AskUserOption {\n label: string;\n description?: string;\n}\n\n/**\n * Controls whether an `ask_user` prompt accepts one choice or multiple choices.\n *\n * `single_select` is the default for prompts that provide options, preserving the\n * original one-answer behavior. `multi_select` tells the host that the user may choose\n * more than one option and resume with those selections as an array.\n */\nexport type AskUserSelectionMode = 'single_select' | 'multi_select';\n\n/**\n * Answer shape used to resume a suspended `ask_user` call.\n *\n * Free-text and single-select prompts resume with a string. Multi-select prompts\n * resume with a string array containing each selected option label.\n */\nexport type AskUserAnswer = string | string[];\n\n/**\n * Payload carried by the native `tool-call-suspended` event when `ask_user` pauses.\n * Hosts read this to render the question, choices, and selection mode.\n */\nexport interface AskUserSuspendPayload {\n question: string;\n options?: AskUserOption[];\n selectionMode?: AskUserSelectionMode;\n}\n\nconst optionSchema = z.object({\n label: z.string().describe('Short display text for this option (1-5 words)'),\n description: z.string().optional().describe('Explanation of what this option means'),\n});\n\n/**\n * Converts the resume answer into the text returned to the model after `ask_user`\n * resumes. Free-text and single-select prompts already produce a single string,\n * while multi-select prompts resume with an array of selected labels that must be\n * flattened before the tool result is added back into the generation context.\n *\n * The formatter keeps the model-facing output compact by joining multi-select\n * answers with commas, mirroring the single-answer behavior while still preserving\n * every selected option in a readable form.\n */\nexport function formatQuestionAnswer(answer: AskUserAnswer): string {\n return Array.isArray(answer) ? answer.join(', ') : answer;\n}\n\n/**\n * Built-in, agent-agnostic tool: ask the user a question and wait for their response.\n *\n * The tool supports three prompt shapes. Omitting `options` asks an open-ended\n * free-text question. Providing `options` without `selectionMode` asks the host to\n * render a single-select prompt for backwards compatibility. Providing\n * `selectionMode: 'multi_select'` lets the host resume with multiple selected option\n * labels as a string array.\n *\n * Pausing uses the agent-native tool suspension primitive: the tool calls\n * `suspend({ question, options, selectionMode })`, which makes the agent emit a\n * `tool-call-suspended` event and persist run state. The host renders the question,\n * collects the user's answer, and continues the run via `agent.resumeStream(answer)`;\n * the tool re-runs with `resumeData` set to the answer and returns it to the model.\n *\n * When executed without an agent `suspend` (e.g. direct invocation outside an agent\n * run), the tool returns a readable fallback prompt so the question and choices are\n * still surfaced.\n */\nexport const askUserTool = createTool({\n id: 'ask_user',\n description:\n 'Ask the user a question and wait for their response. Use this when you need clarification, want to validate assumptions, or need the user to make a decision between options. Provide options for structured choices (2-4 options), or omit them for open-ended questions. Use selectionMode to choose whether the user can pick one option or multiple options.',\n inputSchema: z.object({\n question: z.string().min(1).describe('The question to ask the user. Should be clear and specific.'),\n options: z\n .array(optionSchema)\n .optional()\n .describe('Optional choices. If provided, shows a selection list. If omitted, shows a free-text input.'),\n selectionMode: z\n .enum(['single_select', 'multi_select'])\n .optional()\n .describe(\n 'Controls how many provided options the user can select. Defaults to single_select when options are provided. Requires options.',\n ),\n }),\n suspendSchema: z.object({\n question: z.string(),\n options: z.array(optionSchema).optional(),\n selectionMode: z.enum(['single_select', 'multi_select']).optional(),\n }),\n resumeSchema: z.union([z.string(), z.array(z.string())]),\n execute: async ({ question, options, selectionMode }, context) => {\n try {\n if (selectionMode && !options?.length) {\n return {\n content: 'Failed to ask user: selectionMode requires options.',\n isError: true,\n };\n }\n\n const resolvedSelectionMode = options?.length ? (selectionMode ?? 'single_select') : undefined;\n\n const resumeData = context?.agent?.resumeData as AskUserAnswer | undefined;\n if (resumeData !== undefined) {\n return { content: `User answered: ${formatQuestionAnswer(resumeData)}`, isError: false };\n }\n\n const suspend = context?.agent?.suspend;\n if (suspend) {\n await suspend({ question, options, selectionMode: resolvedSelectionMode });\n return;\n }\n\n // No agent context available: surface the question as readable text so non-agent\n // execution paths still expose the question and available choices to the model.\n return {\n content: `[Question for user]: ${question}${\n options?.length ? '\\nOptions: ' + options.map(o => o.label).join(', ') : ''\n }${resolvedSelectionMode ? '\\nSelection mode: ' + resolvedSelectionMode : ''}`,\n isError: false,\n };\n } catch (error) {\n const msg = error instanceof Error ? error.message : 'Unknown error';\n return { content: `Failed to ask user: ${msg}`, isError: true };\n }\n },\n});\n","import { lookup as dnsLookup } from 'node:dns';\nimport type { LookupAddress, LookupOptions } from 'node:dns';\nimport http from 'node:http';\nimport https from 'node:https';\nimport net from 'node:net';\n\nimport { z } from 'zod/v4';\n\nimport { createTool } from '../tool';\n\nconst MAX_CONTENT_LENGTH = 100_000;\nconst MAX_REDIRECTS = 5;\nconst TIMEOUT_MS = 15_000;\n\nclass WebFetchError extends Error {}\n\nfunction parseHttpUrl(url: string): URL | undefined {\n try {\n const parsedUrl = new URL(url);\n return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction isBlockedHostname(hostname: string): boolean {\n const normalizedHostname = hostname.toLowerCase();\n return normalizedHostname === 'localhost' || normalizedHostname.endsWith('.localhost');\n}\n\nfunction isBlockedIpv4(address: string): boolean {\n const parts = address.split('.').map(Number);\n const [first = 0, second = 0] = parts;\n\n return (\n first === 0 ||\n first === 10 ||\n first === 127 ||\n (first === 100 && second >= 64 && second <= 127) ||\n (first === 169 && second === 254) ||\n (first === 172 && second >= 16 && second <= 31) ||\n (first === 192 && second === 0 && parts[2] === 0) ||\n (first === 192 && second === 0 && parts[2] === 2) ||\n (first === 192 && second === 168) ||\n (first === 198 && (second === 18 || second === 19)) ||\n (first === 198 && second === 51 && parts[2] === 100) ||\n (first === 203 && second === 0 && parts[2] === 113) ||\n first >= 224\n );\n}\n\nfunction normalizeHostname(hostname: string): string {\n return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n}\n\nfunction parseIpv4MappedGroups(address: string): number[] | undefined {\n const ipv4Start = address.lastIndexOf(':');\n const ipv4Address = address.slice(ipv4Start + 1);\n\n if (!ipv4Address.includes('.')) {\n return undefined;\n }\n\n const ipv4Parts = ipv4Address.split('.').map(Number);\n if (ipv4Parts.length !== 4 || ipv4Parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) {\n return undefined;\n }\n\n const [first, second, third, fourth] = ipv4Parts as [number, number, number, number];\n\n return [...expandIpv6(address.slice(0, ipv4Start), 6), (first << 8) + second, (third << 8) + fourth];\n}\n\nfunction expandIpv6(address: string, expectedGroups = 8): number[] {\n const [left = '', right = ''] = address.split('::');\n const leftGroups = left ? left.split(':') : [];\n const rightGroups = right ? right.split(':') : [];\n const missingGroups = expectedGroups - leftGroups.length - rightGroups.length;\n const groups = address.includes('::')\n ? [...leftGroups, ...Array(missingGroups).fill('0'), ...rightGroups]\n : leftGroups;\n\n return groups.map(group => Number.parseInt(group || '0', 16));\n}\n\nfunction isBlockedIpv6(address: string): boolean {\n const normalizedAddress = normalizeHostname(address).toLowerCase();\n const groups = normalizedAddress.includes('.')\n ? parseIpv4MappedGroups(normalizedAddress)\n : expandIpv6(normalizedAddress);\n\n if (!groups || groups.length !== 8 || groups.some(group => Number.isNaN(group))) {\n return false;\n }\n\n const [first, second, third, fourth, fifth, sixth, seventh, eighth] = groups as [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const isIpv4Mapped = [first, second, third, fourth, fifth].every(group => group === 0) && sixth === 0xffff;\n\n return (\n groups.every(group => group === 0) ||\n (groups.slice(0, 7).every(group => group === 0) && eighth === 1) ||\n (isIpv4Mapped && isBlockedIpv4([seventh >> 8, seventh & 255, eighth >> 8, eighth & 255].join('.'))) ||\n (first & 0xfe00) === 0xfc00 ||\n (first & 0xffc0) === 0xfe80 ||\n (first & 0xff00) === 0xff00\n );\n}\n\nfunction isBlockedIp(address: string): boolean {\n const normalizedAddress = normalizeHostname(address);\n const ipVersion = net.isIP(normalizedAddress);\n return ipVersion === 4\n ? isBlockedIpv4(normalizedAddress)\n : ipVersion === 6\n ? isBlockedIpv6(normalizedAddress)\n : false;\n}\n\nfunction assertAllowedUrl(url: URL): void {\n const hostname = normalizeHostname(url.hostname);\n\n if (isBlockedHostname(hostname) || isBlockedIp(hostname)) {\n throw new WebFetchError('URL resolves to a private or reserved address.');\n }\n}\n\nfunction createLookup() {\n return (\n hostname: string,\n options: LookupOptions,\n callback: (error: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void,\n ) => {\n dnsLookup(hostname, options, (error, address, family) => {\n if (error) {\n callback(error, address, family);\n return;\n }\n\n const resolvedAddresses = Array.isArray(address) ? address.map(result => result.address) : [address];\n const blockedAddress = resolvedAddresses.find(isBlockedIp);\n\n if (blockedAddress) {\n callback(new WebFetchError('URL resolves to a private or reserved address.'), address, family);\n return;\n }\n\n callback(null, address, family);\n });\n };\n}\n\nasync function readBody(response: http.IncomingMessage): Promise<{ content: string; truncated: boolean }> {\n const decoder = new TextDecoder();\n let content = '';\n let truncated = false;\n\n for await (const chunk of response) {\n content += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Buffer, { stream: true });\n\n if (content.length > MAX_CONTENT_LENGTH) {\n content = content.slice(0, MAX_CONTENT_LENGTH);\n truncated = true;\n response.destroy();\n break;\n }\n }\n\n if (!truncated) {\n content += decoder.decode();\n }\n\n return { content, truncated };\n}\n\nasync function requestUrl(\n url: URL,\n redirectsRemaining = MAX_REDIRECTS,\n): Promise<{\n content: string;\n truncated: boolean;\n status?: number;\n statusText?: string;\n contentType?: string | null;\n url?: string;\n ok?: boolean;\n}> {\n assertAllowedUrl(url);\n\n return new Promise((resolve, reject) => {\n const requestModule = url.protocol === 'https:' ? https : http;\n const request = requestModule.request(\n url,\n {\n headers: {\n 'user-agent': 'Mastra Web Fetch Tool/1.0',\n accept: 'text/html,text/plain,application/json,application/xml;q=0.9,*/*;q=0.8',\n },\n lookup: createLookup(),\n timeout: TIMEOUT_MS,\n },\n response => {\n void (async () => {\n const location = response.headers.location;\n\n if (location && response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {\n response.resume();\n\n if (redirectsRemaining <= 0) {\n throw new WebFetchError(`Too many redirects. Maximum is ${MAX_REDIRECTS}.`);\n }\n\n const nextUrl = parseHttpUrl(new URL(location, url).toString());\n if (!nextUrl) {\n throw new WebFetchError('Redirect target must use HTTP or HTTPS.');\n }\n\n resolve(await requestUrl(nextUrl, redirectsRemaining - 1));\n return;\n }\n\n const { content, truncated } = await readBody(response);\n\n resolve({\n content,\n truncated,\n status: response.statusCode,\n statusText: response.statusMessage,\n contentType: Array.isArray(response.headers['content-type'])\n ? response.headers['content-type'][0]\n : (response.headers['content-type'] ?? null),\n url: url.toString(),\n ok: response.statusCode ? response.statusCode >= 200 && response.statusCode < 300 : false,\n });\n })().catch(reject);\n },\n );\n\n request.on('timeout', () => {\n request.destroy(new WebFetchError(`Request timed out after ${TIMEOUT_MS}ms.`));\n });\n request.on('error', reject);\n request.end();\n });\n}\n\nfunction getErrorMessage(error: unknown): string {\n if (error instanceof Error) {\n return error.message;\n }\n\n return 'Unknown error';\n}\n\nexport const webFetchTool = createTool({\n id: 'web_fetch',\n description: 'Fetch a web page by URL and return text content with basic response metadata.',\n inputSchema: z.object({\n url: z.string().min(1).describe('The fully qualified HTTP or HTTPS URL to fetch.'),\n }),\n outputSchema: z.object({\n content: z.string(),\n truncated: z.boolean().optional(),\n status: z.number().optional(),\n statusText: z.string().optional(),\n contentType: z.string().nullable().optional(),\n url: z.string().optional(),\n ok: z.boolean().optional(),\n isError: z.boolean().optional(),\n }),\n execute: async ({ url }: { url: string }) => {\n const parsedUrl = parseHttpUrl(url);\n\n if (!parsedUrl) {\n return {\n content: 'Failed to fetch URL: only HTTP and HTTPS URLs are supported.',\n isError: true,\n };\n }\n\n try {\n return await requestUrl(parsedUrl);\n } catch (error) {\n return {\n content: `Failed to fetch URL: ${getErrorMessage(error)}`,\n isError: true,\n };\n }\n },\n});\n","import type { ProviderDefinedTool } from '@internal/external-types';\nimport { ErrorCategory, ErrorDomain, MastraError } from '../../error';\n\nconst WEB_SEARCH_TOOL_MARKER = Symbol.for('mastra.tools.webSearchTool');\n\nexport type WebSearchProvider = 'openai' | 'anthropic' | 'google' | 'xai';\nexport type WebSearchProviderToolId =\n | 'openai.web_search'\n | 'anthropic.web_search_20250305'\n | 'google.google_search'\n | 'xai.web_search';\n\nexport type WebSearchToolPlaceholder = {\n readonly [WEB_SEARCH_TOOL_MARKER]: true;\n};\n\nexport const webSearchTool: WebSearchToolPlaceholder = Object.freeze({\n [WEB_SEARCH_TOOL_MARKER]: true,\n});\n\nexport function isWebSearchTool(tool: unknown): tool is WebSearchToolPlaceholder {\n return (\n tool === webSearchTool ||\n (typeof tool === 'object' && tool !== null && (tool as WebSearchToolPlaceholder)[WEB_SEARCH_TOOL_MARKER] === true)\n );\n}\n\nexport function normalizeWebSearchProvider(providerOrModel: unknown): WebSearchProvider {\n const provider = getProviderString(providerOrModel);\n const supportedProviders = new Set<WebSearchProvider>(['openai', 'anthropic', 'google', 'xai']);\n\n if (supportedProviders.has(provider as WebSearchProvider)) {\n return provider as WebSearchProvider;\n }\n\n const routerProvider = getRouterProvider(provider);\n if (supportedProviders.has(routerProvider as WebSearchProvider)) {\n return routerProvider as WebSearchProvider;\n }\n\n throw new MastraError({\n id: 'WEB_SEARCH_UNSUPPORTED_PROVIDER',\n domain: ErrorDomain.AGENT,\n category: ErrorCategory.USER,\n details: {\n provider,\n },\n text: `The built-in webSearchTool supports OpenAI, Anthropic, Google, and xAI models. Could not infer a supported provider from \"${provider}\".`,\n });\n}\n\nexport function createWebSearchProviderTool(provider: WebSearchProvider): ProviderDefinedTool {\n const tool = getWebSearchProviderTool(provider);\n return {\n type: 'provider-defined',\n id: tool.id,\n name: tool.name,\n args: {},\n } as ProviderDefinedTool;\n}\n\nfunction getProviderString(providerOrModel: unknown): string {\n if (typeof providerOrModel === 'string') {\n return providerOrModel;\n }\n\n if (typeof providerOrModel === 'object' && providerOrModel !== null) {\n const model = providerOrModel as { provider?: unknown; modelId?: unknown; id?: unknown };\n if (typeof model.provider === 'string') {\n if (model.provider === 'openai-compatible') {\n if (typeof model.modelId === 'string') {\n return model.modelId;\n }\n\n if (typeof model.id === 'string') {\n return model.id;\n }\n }\n\n return model.provider;\n }\n\n if (typeof model.modelId === 'string') {\n return model.modelId;\n }\n\n if (typeof model.id === 'string') {\n return model.id;\n }\n }\n\n return String(providerOrModel);\n}\n\nfunction getRouterProvider(provider: string): string {\n const slashIndex = provider.indexOf('/');\n return slashIndex > 0 ? provider.slice(0, slashIndex) : provider;\n}\n\nfunction getWebSearchProviderTool(provider: WebSearchProvider): { id: WebSearchProviderToolId; name: string } {\n switch (provider) {\n case 'openai':\n return { id: 'openai.web_search', name: 'web_search' };\n case 'anthropic':\n return { id: 'anthropic.web_search_20250305', name: 'web_search' };\n case 'google':\n return { id: 'google.google_search', name: 'google_search' };\n case 'xai':\n return { id: 'xai.web_search', name: 'web_search' };\n }\n}\n","import { z } from 'zod/v4';\n\nimport { createTool } from '../tool';\n\n/**\n * Payload carried by the native `tool-call-suspended` event when `submit_plan` pauses.\n *\n * The tool knows the plan file `path` on disk. Hosts validate that path, read the plan\n * from it, and fill `title`/`plan` for approval rendering and history replay.\n */\nexport interface SubmitPlanSuspendPayload {\n path: string;\n title?: string;\n plan?: string;\n}\n\n/**\n * The action a host resumes a suspended `submit_plan` call with.\n *\n * `approved` means the user accepted the plan and the agent should proceed. `rejected`\n * means the user wants revisions; the optional `feedback` is surfaced to the model so it\n * can revise and submit again.\n *\n * Hosts that layer additional behavior on approval (e.g. a AgentController switching from a\n * planning mode to an execution mode) drive that from their own response handling; the\n * tool itself only reports the outcome back to the model.\n */\nexport interface SubmitPlanResumeData {\n action: 'approved' | 'rejected';\n feedback?: string;\n path?: string;\n title?: string;\n plan?: string;\n}\n\nconst resumeSchema = z.object({\n action: z.enum(['approved', 'rejected']),\n feedback: z.string().optional(),\n path: z.string().optional(),\n title: z.string().optional(),\n plan: z.string().optional(),\n});\n\n/**\n * Built-in, agent-agnostic tool: submit an implementation plan for user review.\n *\n * Pausing uses the agent-native tool suspension primitive: the tool calls\n * `suspend({ path })`, which makes the agent emit a `tool-call-suspended` event and\n * persist run state. The host validates the plan file path, reads it, renders it,\n * collects an approve/reject decision, and continues the run via `agent.resumeStream({ action,\n * feedback })`; the tool re-runs with `resumeData` set to that decision and reports it\n * back to