@mastra/core
Version:
1,064 lines (1,059 loc) • 38.2 kB
JavaScript
import { randomUUID } from "crypto";
import { z } from "zod/v4";
//#region src/background-tasks/workflow-id.ts
/**
* Workflow id used by the bg-task workflow registered on Mastra.
* Double-underscore prefix marks it as internal — same convention as
* `__batch-scoring-traces`.
*
* Lives in its own file (separate from `./workflow`) so `manager.ts` can
* reference the id without statically pulling in `../workflows/evented`,
* which would create a circular import via `agent → background-tasks →
* workflow → evented → workflows/index → agent`.
*/
const BACKGROUND_TASK_WORKFLOW_ID = "__background-task";
//#endregion
//#region src/background-tasks/manager.ts
const TOPIC_DISPATCH = "background-tasks";
const TOPIC_RESULT = "background-tasks-result";
const WORKER_GROUP = "background-task-workers";
var BackgroundTaskManager = class {
pubsub;
config;
#mastra;
/** @internal — read by the workflow-engine step bodies in workflow.ts */
taskContexts = /* @__PURE__ */ new Map();
staticExecutors = /* @__PURE__ */ new Map();
/** @internal — read by the workflow-engine step bodies in workflow.ts */
activeAbortControllers = /* @__PURE__ */ new Map();
workerCallback;
resultCallback;
shuttingDown = false;
cleanupInterval;
initPromise;
constructor(config = { enabled: false }) {
this.config = {
globalConcurrency: config.globalConcurrency ?? 10,
perAgentConcurrency: config.perAgentConcurrency ?? 5,
backpressure: config.backpressure ?? "queue",
defaultTimeoutMs: config.defaultTimeoutMs ?? 3e5,
...config
};
}
__registerMastra(mastra) {
this.#mastra = mastra;
}
async getStorage() {
const storage = this.#mastra?.getStorage();
if (!storage) throw new Error("Storage is not initialized");
const bgStore = await storage.getStore("backgroundTasks");
if (!bgStore) throw new Error("Background tasks storage is not available");
return bgStore;
}
async init(pubsub) {
if (this.initPromise) return this.initPromise;
this.initPromise = this.#doInit(pubsub);
return this.initPromise;
}
async #doInit(pubsub) {
this.pubsub = pubsub;
const isProducerOnly = this.config.mode === "producer";
this.resultCallback = async (event, ack) => {
if (event.type === "task.completed" || event.type === "task.failed") await this.handleResult(event);
await ack?.();
};
if (!isProducerOnly) {
this.workerCallback = async (event, ack) => {
if (event.type === "task.dispatch" || event.type === "task.restart") await this.handleDispatch(event);
else if (event.type === "task.resume") await this.handleResume(event);
else if (event.type === "task.cancel") this.handleCancel(event);
await ack?.();
};
if (this.#mastra) {
const { buildBackgroundTaskWorkflow } = await import("./workflow-Ch1Z6o3l.js");
const workflow = buildBackgroundTaskWorkflow(this);
if (!this.#mastra.__hasInternalWorkflow("__background-task")) this.#mastra.__registerInternalWorkflow(workflow);
}
await this.pubsub.subscribe(TOPIC_DISPATCH, this.workerCallback, { group: WORKER_GROUP });
}
await this.pubsub.subscribe(TOPIC_RESULT, this.resultCallback);
if (!isProducerOnly) await this.recoverStaleTasks();
const cleanupConfig = this.config.cleanup;
if (cleanupConfig) {
const intervalMs = cleanupConfig.cleanupIntervalMs ?? 6e4;
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, intervalMs);
}
}
/**
* Register per-task hooks (executor, stream emitter, result injector).
* Called internally by createBackgroundTask or directly for advanced usage.
*/
registerTaskContext(taskId, context) {
this.taskContexts.set(taskId, context);
}
/**
* Remove per-task hooks. Called after task reaches terminal state.
*/
deregisterTaskContext(taskId) {
this.taskContexts.delete(taskId);
}
/**
* Register a tool executor by tool name. Used for cross-process dispatch:
* when a worker in a different process picks up a `task.dispatch` event,
* it has no per-task closure (`taskContexts`) for that taskId, but it can
* resolve the executor by tool name via this registry.
*/
registerStaticExecutor(toolName, executor) {
if (this.staticExecutors.has(toolName)) this.#mastra?.getLogger?.()?.debug?.(`Overwriting existing static executor for tool "${toolName}"`);
this.staticExecutors.set(toolName, executor);
}
/**
* Symmetric to `registerStaticExecutor`. Called when a tool is removed
* from `Mastra`.
*/
unregisterStaticExecutor(toolName) {
this.staticExecutors.delete(toolName);
}
/**
* Look up an executor by tool name. Read by the workflow-step body in
* `workflow.ts:runAttemptStep` as a fallback when no per-task `TaskContext`
* is registered (cross-process path).
*/
getStaticExecutor(toolName) {
return this.staticExecutors.get(toolName);
}
/**
* Enqueue a task for background execution.
* Prefer `createBackgroundTask()` which returns a self-contained handle.
*/
async enqueue(payload, context) {
if (this.shuttingDown) throw new Error("BackgroundTaskManager is shutting down, cannot enqueue new tasks");
if (this.initPromise) await this.initPromise;
const task = {
id: this.#mastra?.generateId() ?? randomUUID(),
status: "pending",
toolName: payload.toolName,
toolCallId: payload.toolCallId,
args: payload.args,
agentId: payload.agentId,
threadId: payload.threadId,
resourceId: payload.resourceId,
runId: payload.runId,
retryCount: 0,
maxRetries: payload.maxRetries ?? this.config.defaultRetries?.maxRetries ?? 0,
timeoutMs: payload.timeoutMs ?? this.config.defaultTimeoutMs,
createdAt: /* @__PURE__ */ new Date()
};
if (context) this.registerTaskContext(task.id, context);
const storage = await this.getStorage();
await storage.createTask(task);
if (await this.checkConcurrency(task.agentId)) {
await this.dispatch(task);
return { task };
}
switch (this.config.backpressure) {
case "reject":
this.deregisterTaskContext(task.id);
await storage.deleteTask(task.id);
throw new Error(`Concurrency limit reached, cannot enqueue task for tool "${task.toolName}"`);
case "fallback-sync":
this.deregisterTaskContext(task.id);
await storage.deleteTask(task.id);
return {
task,
fallbackToSync: true
};
default: return { task };
}
}
async cancel(taskId) {
if (this.initPromise) await this.initPromise;
const storage = await this.getStorage();
const task = await storage.getTask(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled" || task.status === "timed_out") return;
if (task.status === "pending") {
await storage.updateTask(taskId, {
status: "cancelled",
completedAt: /* @__PURE__ */ new Date()
});
const cancelledTask = await storage.getTask(taskId);
if (cancelledTask) await this.publishLifecycleEvent("task.cancelled", cancelledTask);
this.deregisterTaskContext(taskId);
return;
}
if (task.status === "suspended") {
await storage.updateTask(taskId, {
status: "cancelled",
completedAt: /* @__PURE__ */ new Date()
});
if (this.#mastra) try {
await (await this.#mastra.__getInternalWorkflow(BACKGROUND_TASK_WORKFLOW_ID).createRun({ runId: taskId })).cancel();
} catch (err) {
this.#mastra?.getLogger?.()?.warn(`background-task workflow cancel failed for ${taskId}:`, err);
}
const cancelledTask = await storage.getTask(taskId);
if (cancelledTask) await this.publishLifecycleEvent("task.cancelled", cancelledTask);
this.deregisterTaskContext(taskId);
return;
}
if (task.status === "running") {
await storage.updateTask(taskId, {
status: "cancelled",
completedAt: /* @__PURE__ */ new Date()
});
const controller = this.activeAbortControllers.get(taskId);
if (controller) {
controller.abort(/* @__PURE__ */ new Error("Task cancelled"));
this.activeAbortControllers.delete(taskId);
}
if (this.#mastra) try {
await (await this.#mastra.__getInternalWorkflow(BACKGROUND_TASK_WORKFLOW_ID).createRun({ runId: taskId })).cancel();
} catch (err) {
this.#mastra?.getLogger?.()?.warn(`background-task workflow cancel failed for ${taskId}:`, err);
}
const cancelledTask = await storage.getTask(taskId);
if (cancelledTask) await this.publishLifecycleEvent("task.cancelled", cancelledTask);
this.deregisterTaskContext(taskId);
await this.pubsub.publish(TOPIC_DISPATCH, {
type: "task.cancel",
data: { taskId },
runId: taskId
});
}
}
/**
* Resume a suspended task. The tool executor must be re-registered via
* `registerTaskContext(taskId, ...)` before calling this if the original
* registration is gone (e.g. process restart) — the manager doesn't
* rehydrate executor closures from storage.
*
* `resumeData` is forwarded to the tool's `execute` options on the
* resumed run.
*/
async resume(taskId, resumeData) {
if (!this.#mastra) throw new Error("Mastra is not registered with this manager");
if (this.initPromise) await this.initPromise;
const task = await (await this.getStorage()).getTask(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
if (task.status !== "suspended") throw new Error(`Cannot resume task in status '${task.status}' (expected 'suspended')`);
if (!await this.checkConcurrency(task.agentId)) throw new Error(`Concurrency limit reached, cannot resume task "${taskId}" — retry once a slot is available`);
await this.#ensureExecutionWorkersStarted();
await this.pubsub.publish(TOPIC_DISPATCH, {
type: "task.resume",
data: {
taskId,
resumeData
},
runId: taskId
});
return task;
}
/**
* Restarts a previously running task. The tool executor is re-registered via
* `registerTaskContext(taskId, ...)` because the original
* registration is gone (e.g. process restart) — the manager doesn't
* rehydrate executor closures from storage.
*
*/
async restart(taskId, context) {
if (!this.#mastra) throw new Error("Mastra is not registered with this manager");
if (this.initPromise) await this.initPromise;
const task = await (await this.getStorage()).getTask(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
if (task.status !== "running") throw new Error(`Cannot restart task in status '${task.status}' (expected 'running')`);
if (context) this.registerTaskContext(task.id, context);
if (!await this.checkConcurrency(task.agentId)) throw new Error(`Concurrency limit reached, cannot restart task "${taskId}" — retry once a slot is available`);
await this.dispatch(task, true);
return task;
}
async getTask(taskId) {
return (await this.getStorage()).getTask(taskId);
}
async listTasks(filter = {}) {
return (await this.getStorage()).listTasks(filter);
}
/**
* Deletes old completed/failed/cancelled/timed_out task records from storage.
*/
async cleanup() {
const completedTtlMs = this.config.cleanup?.completedTtlMs ?? 36e5;
const failedTtlMs = this.config.cleanup?.failedTtlMs ?? 864e5;
const now = Date.now();
const storage = await this.getStorage();
await storage.deleteTasks({
status: ["completed"],
toDate: new Date(now - completedTtlMs),
dateFilterBy: "completedAt"
});
await storage.deleteTasks({
status: [
"failed",
"cancelled",
"timed_out"
],
toDate: new Date(now - failedTtlMs),
dateFilterBy: "completedAt"
});
}
/**
* Returns a promise that resolves when the next task from the given set
* reaches a terminal state.
*/
async waitForNextTask(taskIds, options) {
const storage = await this.getStorage();
const isTerminal = (status) => status === "completed" || status === "failed" || status === "cancelled" || status === "timed_out";
for (const id of taskIds) {
const task = await storage.getTask(id);
if (task && isTerminal(task.status)) return task;
}
return new Promise((resolve, reject) => {
const startTime = Date.now();
const timeout = options?.timeoutMs ? setTimeout(() => {
clearInterval(pollInterval);
if (progressInterval) clearInterval(progressInterval);
reject(/* @__PURE__ */ new Error("Timed out waiting for background task"));
}, options.timeoutMs) : void 0;
const progressInterval = options?.onProgress ? setInterval(() => {
options.onProgress(Date.now() - startTime);
}, options.progressIntervalMs ?? 3e3) : void 0;
const pollInterval = setInterval(async () => {
for (const id of taskIds) {
const task = await storage.getTask(id);
if (task && isTerminal(task.status)) {
clearInterval(pollInterval);
if (timeout) clearTimeout(timeout);
if (progressInterval) clearInterval(progressInterval);
resolve(task);
return;
}
}
}, 50);
});
}
/**
* Returns a ReadableStream of all background task lifecycle events,
* filtered by optional criteria. Intended to be piped directly to an SSE response.
*
* On connection, emits the current state of all non-terminal tasks as a snapshot,
* then subscribes to live pubsub events for subsequent updates.
*
* Events include:
* - `task.running` (status: 'running') — task picked up by a worker
* - `task.completed` (status: 'completed') — task finished successfully
* - `task.failed` (status: 'failed' or 'timed_out') — task errored or timed out
* - `task.cancelled` (status: 'cancelled') — task was cancelled
* - `task.suspended` (status: 'suspended') — task paused via `suspend()` from
* inside its tool executor; resume with `manager.resume(taskId, data)`
* - `task.resumed` (status: 'running') — suspended task resumed
*
* The stream stays open until the caller's AbortSignal fires (client disconnect).
*/
stream(options) {
const manager = this;
const pubsub = this.pubsub;
const { agentId, runId, threadId, resourceId, abortSignal, taskId } = options ?? {};
const EVENT_STATUS_MAP = {
"task.running": "running",
"task.output": "running",
"task.completed": "completed",
"task.failed": "failed",
"task.cancelled": "cancelled",
"task.suspended": "suspended",
"task.resumed": "running"
};
const CHUNK_EVENT_MAP = {
"task.running": "background-task-running",
"task.output": "background-task-output",
"task.completed": "background-task-completed",
"task.failed": "background-task-failed",
"task.cancelled": "background-task-cancelled",
"task.suspended": "background-task-suspended",
"task.resumed": "background-task-resumed"
};
return new ReadableStream({ async start(controller) {
const handler = async (event) => {
if (!EVENT_STATUS_MAP[event.type]) return;
const data = event.data;
if (agentId && data.agentId !== agentId) return;
if (runId && data.runId !== runId) return;
if (threadId && data.threadId !== threadId) return;
if (resourceId && data.resourceId !== resourceId) return;
if (taskId && data.taskId !== taskId) return;
const payload = {
taskId: data.taskId,
toolName: data.toolName,
toolCallId: data.toolCallId,
agentId: data.agentId,
runId: data.runId
};
switch (event.type) {
case "task.running":
payload.startedAt = data.startedAt;
payload.args = data.args;
break;
case "task.completed":
payload.completedAt = data.completedAt;
payload.result = data.result;
break;
case "task.failed":
payload.completedAt = data.completedAt;
payload.error = data.error;
break;
case "task.cancelled":
payload.completedAt = data.completedAt;
break;
case "task.output":
payload.payload = data.chunk;
break;
case "task.suspended":
payload.suspendPayload = data.suspendPayload;
payload.suspendedAt = data.suspendedAt;
payload.args = data.args;
break;
case "task.resumed":
payload.startedAt = data.startedAt;
payload.args = data.args;
break;
}
try {
controller.enqueue({
type: CHUNK_EVENT_MAP[event.type],
payload
});
} catch {}
};
pubsub.subscribe(TOPIC_RESULT, handler);
abortSignal?.addEventListener("abort", () => {
pubsub.unsubscribe(TOPIC_RESULT, handler);
try {
controller.close();
} catch {}
});
try {
const storage = await manager.getStorage();
if (taskId) {
const task = await storage.getTask(taskId);
if (task && task.status === "running") controller.enqueue({
type: "background-task-running",
payload: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
agentId: task.agentId,
runId: task.runId,
startedAt: task.startedAt,
args: task.args
}
});
} else {
const { tasks: existing } = await storage.listTasks({
agentId,
runId,
threadId,
resourceId,
status: ["running"]
});
for (const task of existing) {
if (abortSignal?.aborted) break;
try {
controller.enqueue({
type: "background-task-running",
payload: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
agentId: task.agentId,
runId: task.runId,
startedAt: task.startedAt,
args: task.args
}
});
} catch {
break;
}
}
}
} catch {}
} });
}
async shutdown() {
this.shuttingDown = true;
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = void 0;
}
if (this.workerCallback) await this.pubsub.unsubscribe(TOPIC_DISPATCH, this.workerCallback);
if (this.resultCallback) await this.pubsub.unsubscribe(TOPIC_RESULT, this.resultCallback);
this.taskContexts.clear();
await this.pubsub.flush();
}
/**
* Lazily start Mastra's execution workers before publishing. In "library
* mode" nothing ever calls `mastra.startWorkers()`, so the evented
* `__background-task` workflow started by `handleDispatch` would publish
* to the `workflows` topic with no consumer and the task would sit at
* `running` forever (#19339). A no-op once workers are running; honors the
* `workers: false` / `MASTRA_WORKERS` opt-outs.
*
* Startup failures are logged but don't abort the publish — in distributed
* topologies a remote worker on the shared broker can still pick the task
* up, and throwing here would also abort `drainPending()` /
* `recoverStaleTasks()` loops.
*/
async #ensureExecutionWorkersStarted() {
if (!this.#mastra) return;
try {
await this.#mastra.__ensureExecutionWorkersStarted();
} catch (err) {
this.#mastra.getLogger?.()?.error("Failed to start execution workers for background task", err);
}
}
async dispatch(task, isRestart) {
await this.#ensureExecutionWorkersStarted();
await this.pubsub.publish(TOPIC_DISPATCH, {
type: "task.dispatch",
data: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
args: task.args,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
timeoutMs: task.timeoutMs,
maxRetries: task.maxRetries,
runId: task.runId,
isRestart
},
runId: task.id
});
}
/**
* Handles a task.dispatch and task.restart events.
* Both events are similar, but the latter is used to restart a running task.
*/
async handleDispatch(event) {
const { taskId, isRestart } = event.data;
const deliveryAttempt = event.deliveryAttempt ?? 1;
const storage = await this.getStorage();
const task = await storage.getTask(taskId);
if (!task || task.status === "cancelled") {
this.deregisterTaskContext(taskId);
return;
}
if (isRestart && task.status !== "running") return;
await storage.updateTask(taskId, {
status: "running",
startedAt: /* @__PURE__ */ new Date(),
retryCount: deliveryAttempt - 1
});
const runningTask = await storage.getTask(taskId);
if (runningTask) await this.publishLifecycleEvent("task.running", runningTask);
if (this.#mastra) {
if (runningTask) this.runLocalExecutionHook(runningTask);
const workflow = this.#mastra.__getInternalWorkflow(BACKGROUND_TASK_WORKFLOW_ID);
const prevWorkflowRun = isRestart ? await workflow.getWorkflowRunById(taskId) : void 0;
const shouldRestart = isRestart && prevWorkflowRun?.status === "running";
const run = await workflow.createRun({ runId: taskId });
(shouldRestart ? run.restart() : run.start({ inputData: { taskId } })).then((result) => {
if (result.status !== "suspended") workflow.deleteWorkflowRunById(taskId);
}).catch((err) => {
this.#mastra?.getLogger?.()?.error(`background-task workflow ${shouldRestart ? "restart" : "start"} failed for ${taskId}:`, err);
}).finally(() => {
this.drainPending();
});
}
}
/**
* Handles a task.resume event. Mirrors the workflow branch of handleDispatch
* but resumes an existing run from its suspended snapshot instead of starting
* a fresh one. Concurrency gating, suspended-status validation, and the
* `task.resumed` lifecycle publish all happen here so a different process
* than the one that suspended the task can drive the resume.
*/
async handleResume(event) {
const { taskId, resumeData } = event.data;
const storage = await this.getStorage();
const task = await storage.getTask(taskId);
if (!task || task.status !== "suspended") return;
await storage.updateTask(taskId, {
status: "running",
startedAt: /* @__PURE__ */ new Date(),
suspendPayload: void 0,
suspendedAt: void 0
});
const resumedTask = await storage.getTask(taskId);
if (resumedTask) await this.publishLifecycleEvent("task.resumed", resumedTask);
if (!this.#mastra) return;
const workflow = this.#mastra.__getInternalWorkflow(BACKGROUND_TASK_WORKFLOW_ID);
(await workflow.createRun({ runId: taskId })).resume({ resumeData }).then((result) => {
if (result.status !== "suspended") workflow.deleteWorkflowRunById(taskId);
}).catch((err) => {
this.#mastra?.getLogger?.()?.error(`background-task workflow resume failed for ${taskId}:`, err);
}).finally(() => {
this.drainPending();
});
}
/**
* Run per-task hooks (onChunk, onResult, onComplete/onFailed) locally in the
* worker path, before publishing the terminal lifecycle event. Ensures
* memory / stream state is consistent by the time any pubsub subscriber is
* notified. After running, the task context is deregistered so
* `handleResult` (which also fires from pubsub) becomes a no-op for this
* task in the same process.
*
* In distributed deployments where the worker runs in a different process
* from the dispatcher, `this.taskContexts` won't contain an entry for
* `task.id` — this method is a no-op there, and `handleResult` in the
* dispatching process runs the hooks instead.
*/
/**
* Terminal-state hooks only. Called when a task reaches `'completed'` or
* `'failed'`. Suspend is non-terminal — see `runLocalSuspendHooks` for that
* path.
*
* @internal — also called by the workflow-engine step bodies in workflow.ts
*/
async runLocalCompletionHooks(task, status, extras) {
const ctx = this.taskContexts.get(task.id);
if (!ctx) return;
try {
if (status === "completed") {
ctx.onChunk?.({
type: "background-task-completed",
payload: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
runId: task.runId,
result: extras.result,
completedAt: task.completedAt,
agentId: task.agentId
}
});
await ctx.onResult?.({
runId: task.runId,
taskId: task.id,
toolCallId: task.toolCallId,
toolName: task.toolName,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
result: extras.result,
status: "completed",
completedAt: task.completedAt,
startedAt: task.startedAt
});
await ctx.onComplete?.(task);
} else {
ctx.onChunk?.({
type: "background-task-failed",
payload: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
runId: task.runId,
error: extras.error ?? { message: "Unknown error" },
completedAt: task.completedAt,
agentId: task.agentId
}
});
await ctx.onResult?.({
runId: task.runId,
taskId: task.id,
toolCallId: task.toolCallId,
toolName: task.toolName,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
error: extras.error,
status: "failed",
completedAt: task.completedAt,
startedAt: task.startedAt
});
await ctx.onFailed?.(task);
}
} finally {
this.deregisterTaskContext(task.id);
}
}
/**
* Per-task suspend hooks. Fires `ctx.onResult({ status: 'suspended', ... })`
* so the message list / memory pick up the suspension as the tool's
* current invocation state. Does NOT deregister the task context — resume
* needs the executor closure intact.
*
* @internal — called by the workflow-engine step bodies in workflow.ts
*/
async runLocalSuspendHooks(task) {
const ctx = this.taskContexts.get(task.id);
if (!ctx) return;
await ctx.onExecution?.({
runId: task.runId,
taskId: task.id,
toolCallId: task.toolCallId,
toolName: task.toolName,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
startedAt: task.startedAt,
suspendedAt: task.suspendedAt
});
}
/** @internal — also called by the workflow-engine step bodies in workflow.ts */
async runLocalExecutionHook(task) {
const ctx = this.taskContexts.get(task.id);
if (!ctx) return;
try {
await ctx.onExecution?.({
runId: task.runId,
taskId: task.id,
toolCallId: task.toolCallId,
toolName: task.toolName,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
startedAt: task.startedAt
});
} catch {}
}
async handleResult(event) {
const { taskId, toolName, toolCallId, threadId, resourceId, runId } = event.data;
const task = await (await this.getStorage()).getTask(taskId);
if (task?.completedAt) {
const ctx = this.taskContexts.get(taskId);
if (event.type === "task.completed") {
ctx?.onChunk?.({
type: "background-task-completed",
payload: {
taskId,
toolName,
toolCallId,
runId,
result: event.data.result,
completedAt: task.completedAt,
agentId: task.agentId
}
});
await ctx?.onResult?.({
runId,
taskId,
toolCallId,
toolName,
agentId: event.data.agentId,
threadId,
resourceId,
result: event.data.result,
status: "completed",
completedAt: task.completedAt,
startedAt: task.startedAt
});
if (task) await Promise.all([ctx?.onComplete?.(task), this.config.onTaskComplete?.(task)]);
}
if (event.type === "task.failed") {
ctx?.onChunk?.({
type: "background-task-failed",
payload: {
taskId,
toolName,
toolCallId,
runId,
error: event.data.error,
completedAt: task.completedAt,
agentId: task.agentId
}
});
await ctx?.onResult?.({
runId,
taskId,
toolCallId,
toolName,
agentId: event.data.agentId,
threadId,
resourceId,
error: event.data.error,
status: "failed",
completedAt: task.completedAt,
startedAt: task.startedAt
});
if (task) await Promise.all([ctx?.onFailed?.(task), this.config.onTaskFailed?.(task)]);
}
this.deregisterTaskContext(taskId);
}
}
handleCancel(event) {
const { taskId } = event.data;
const controller = this.activeAbortControllers.get(taskId);
if (controller) {
controller.abort(/* @__PURE__ */ new Error("Task cancelled"));
this.activeAbortControllers.delete(taskId);
}
this.deregisterTaskContext(taskId);
}
/** @internal — also called by the workflow-engine step bodies in workflow.ts */
async publishLifecycleEvent(type, task) {
await this.pubsub.publish(TOPIC_RESULT, {
type,
data: {
taskId: task.id,
toolName: task.toolName,
toolCallId: task.toolCallId,
runId: task.runId,
agentId: task.agentId,
threadId: task.threadId,
resourceId: task.resourceId,
args: task.args,
result: task.result,
error: task.error,
chunk: task.chunk,
completedAt: task.completedAt,
startedAt: task.startedAt,
suspendPayload: task.suspendPayload,
suspendedAt: task.suspendedAt
},
runId: task.id
});
}
async checkConcurrency(agentId) {
const storage = await this.getStorage();
if (await storage.getRunningCount() >= this.config.globalConcurrency) return false;
if (await storage.getRunningCountByAgent(agentId) >= this.config.perAgentConcurrency) return false;
return true;
}
async drainPending() {
const { tasks: pending } = await (await this.getStorage()).listTasks({
status: "pending",
orderBy: "createdAt",
orderDirection: "asc"
});
for (const task of pending) if (await this.checkConcurrency(task.agentId)) await this.dispatch(task);
}
/**
* Recovers tasks left in 'running' or 'pending' state from a previous process.
*/
async recoverStaleTasks() {
try {
const storage = await this.getStorage();
const { tasks: staleTasks } = await storage.listTasks({ status: "running" });
for (const task of staleTasks) if (task.maxRetries > 0) await storage.updateTask(task.id, {
status: "pending",
startedAt: void 0
});
else await storage.updateTask(task.id, {
status: "failed",
error: { message: "Worker process terminated before task completed" },
completedAt: /* @__PURE__ */ new Date()
});
const { tasks: pendingTasks } = await storage.listTasks({
status: "pending",
orderBy: "createdAt",
orderDirection: "asc"
});
for (const task of pendingTasks) if (await this.checkConcurrency(task.agentId)) await this.dispatch(task);
} catch (error) {
const logger = this.#mastra?.getLogger();
if (logger) logger.error("Failed to recover stale background tasks", error);
}
}
};
//#endregion
//#region src/background-tasks/create.ts
/**
* Creates a self-contained background task handle.
*
* Bundles the task payload with per-stream hooks (executor, onChunk, onResult)
* so each dispatch is fully isolated — no shared mutable state on the manager.
*
* @example
* ```ts
* const bgTask = createBackgroundTask(manager, {
* toolName: 'research',
* toolCallId: 'call-1',
* args: { query: 'solana' },
* agentId: 'agent-1',
* runId: 'run-1',
* context: {
* executor: { execute: (args, opts) => tool.execute(args, opts) },
* onChunk: (chunk) => controller.enqueue(chunk),
* onResult: (params) => messageList.addToolResult(params),
* },
* });
*
* const { task, fallbackToSync } = await bgTask.dispatch();
* const completed = await bgTask.waitForCompletion();
* await bgTask.cancel();
* ```
*/
function createBackgroundTask(manager, options) {
const { context, ...payload } = options;
let taskId;
return {
get task() {
if (!taskId) throw new Error("Task has not been dispatched yet");
return { id: taskId };
},
async dispatch() {
const result = await manager.enqueue(payload, context);
taskId = result.task.id;
return result;
},
async checkIfSuspended(args) {
const result = await manager.listTasks({
toolCallId: args.toolCallId,
runId: args.runId,
agentId: args.agentId,
threadId: args.threadId,
resourceId: args.resourceId,
toolName: args.toolName,
status: "suspended"
});
if (result.total > 0) {
const task = result.tasks[0];
if (task) {
taskId = task.id;
return true;
}
}
return false;
},
async checkIfRunning(args) {
const result = await manager.listTasks({
toolCallId: args.toolCallId,
runId: args.runId,
agentId: args.agentId,
threadId: args.threadId,
resourceId: args.resourceId,
toolName: args.toolName,
status: "running"
});
if (result.total > 0) {
const task = result.tasks[0];
if (task) {
taskId = task.id;
return true;
}
}
return false;
},
async resume(resumeData) {
if (!taskId) throw new Error("Task has not been dispatched yet");
return manager.resume(taskId, resumeData);
},
async restart() {
if (!taskId) throw new Error("Task has not been dispatched yet");
return manager.restart(taskId, context);
},
async cancel() {
if (!taskId) throw new Error("Task has not been dispatched yet");
return manager.cancel(taskId);
},
async waitForCompletion(waitOptions) {
if (!taskId) throw new Error("Task has not been dispatched yet");
return manager.waitForNextTask([taskId], waitOptions);
}
};
}
//#endregion
//#region src/background-tasks/resolve-config.ts
/**
* Resolves whether a tool call should run in the background, and with what config.
*
* Resolution order (highest to lowest priority):
* 1. LLM per-call override (`_background` field in tool args)
* 2. Agent-level backgroundTasks.tools config
* 3. Tool-level background config
* 4. Default: foreground
*
* Strips the `_background` field from args (mutates the args object).
*/
function resolveBackgroundConfig({ llmBgOverrides, toolName, toolConfig, agentConfig, managerConfig }) {
const llmOverride = llmBgOverrides;
if (agentConfig?.disabled) return {
runInBackground: false,
timeoutMs: managerConfig?.defaultTimeoutMs ?? 3e5,
maxRetries: managerConfig?.defaultRetries?.maxRetries ?? 0
};
const agentToolConfig = resolveAgentToolConfig(toolName, agentConfig);
return {
runInBackground: agentToolConfig?.enabled ?? toolConfig?.enabled ?? false ? llmOverride?.enabled ?? true : false,
timeoutMs: llmOverride?.timeoutMs ?? agentToolConfig?.timeoutMs ?? toolConfig?.timeoutMs ?? managerConfig?.defaultTimeoutMs ?? 3e5,
maxRetries: llmOverride?.maxRetries ?? toolConfig?.maxRetries ?? managerConfig?.defaultRetries?.maxRetries ?? 0
};
}
function resolveAgentToolConfig(toolName, agentConfig) {
if (!agentConfig?.tools) return void 0;
if (agentConfig.tools === "all") return { enabled: true };
if (toolName.startsWith("agent-")) toolName = toolName.substring(6);
else if (toolName.startsWith("workflow-")) toolName = toolName.substring(9);
const entry = agentConfig.tools[toolName];
if (entry === void 0) return void 0;
if (typeof entry === "boolean") return { enabled: entry };
return entry;
}
//#endregion
//#region src/background-tasks/schema-injection.ts
/**
* JSON Schema definition for the `_background` override field.
* Injected into background-eligible tool schemas so the LLM can override behavior per-call.
*/
const backgroundOverrideJsonSchema = {
type: "object",
description: "Optional: override background execution behavior for this specific call. Set enabled=false to force foreground, enabled=true to force background. Omit entirely to use the default configuration.",
properties: {
enabled: {
type: "boolean",
description: "Force background (true) or foreground (false) execution for this call."
},
timeoutMs: {
type: "number",
description: "Override timeout in milliseconds for this call."
},
maxRetries: {
type: "number",
description: "Override maximum retry attempts for this call."
}
},
additionalProperties: false
};
const backgroundOverrideZodSchema = z.object({
enabled: z.boolean().optional().describe("Force background (true) or foreground (false) execution for this call."),
timeoutMs: z.number().optional().describe("Override timeout in milliseconds for this call."),
maxRetries: z.number().optional().describe("Override maximum retry attempts for this call.")
}).optional().describe("Optional: override background execution behavior for this specific call. Set enabled=false to force foreground, enabled=true to force background. Omit entirely to use the default configuration.");
//#endregion
//#region src/background-tasks/system-prompt.ts
/**
* Generates the system prompt section that tells the LLM about background task capabilities.
*
* Returns undefined if no tools are background-eligible (nothing to inject).
*/
function generateBackgroundTaskSystemPrompt(tools, agentConfig) {
const eligibleTools = [];
const enableAll = agentConfig?.tools === "all";
for (const [toolName, tool] of Object.entries(tools)) {
const bgEnabledFromAgentConfig = agentConfig?.tools === "all" ? false : typeof agentConfig?.tools?.[toolName] === "boolean" ? agentConfig.tools[toolName] : agentConfig?.tools?.[toolName]?.enabled ?? false;
eligibleTools.push({
toolName,
toolConfig: tool.background,
defaultBackground: enableAll ? true : bgEnabledFromAgentConfig ?? tool.background?.enabled ?? false
});
}
if (eligibleTools.length === 0) return;
return `You have the ability to run certain tools in the background while continuing the conversation. The following tools support background execution:
${eligibleTools.map((t) => `- ${t.toolName} (default: ${t.defaultBackground ? "background" : "foreground"})`).join("\n")}
For any of these tools, you can include a "_background" field in the tool arguments to override the default:
"_background": { "enabled": true/false, "timeoutMs": number, "maxRetries": number }
All fields in "_background" are optional. Only include what you want to override.
Guidelines:
- Use background execution when the user doesn't need the result immediately, or when you're launching multiple independent tasks.
- Use foreground execution when the user is directly waiting for the result and the conversation can't continue without it.
- If you don't include "_background", the tool's default configuration is used.
- When a tool runs in the background, you'll receive a placeholder result with a task ID. You can reference this in your response to the user.
IMPORTANT: "_background" field is always an object. The fields in the _background field should be inside the _background object, not outside of it.`;
}
//#endregion
export { createBackgroundTask as a, resolveBackgroundConfig as i, backgroundOverrideJsonSchema as n, BackgroundTaskManager as o, backgroundOverrideZodSchema as r, BACKGROUND_TASK_WORKFLOW_ID as s, generateBackgroundTaskSystemPrompt as t };
//# sourceMappingURL=background-tasks-6lJjk3_t.js.map