// Props
import { EscalationChain } from "smthrs";
type EscalationChainProps = {
id?: string; // default "escalation"
levels: EscalationLevel[];
humanFallback?: boolean; // default false
humanRequest?: ApprovalRequest;
escalationOutput: z.ZodObject | { $inferSelect: Record<string, unknown> } | string;
skipIf?: boolean;
children?: ReactNode; // prompt forwarded to every level
};
type EscalationLevel = {
agent: AgentLike;
output: z.ZodObject | { $inferSelect: Record<string, unknown> } | string;
label?: string;
escalateIf?: (result: unknown) => boolean; // true -> next level
};
<Workflow name="support-ticket">
<EscalationChain
id="support"
escalationOutput={outputs.escalation}
humanFallback
humanRequest={{ title: "Ticket needs human support", summary: "Agents could not resolve." }}
levels={[
{ agent: fastAgent, output: outputs.tier1, label: "Tier 1", escalateIf: (r) => r.confidence < 0.7 },
{ agent: powerAgent, output: outputs.tier2, label: "Tier 2", escalateIf: (r) => r.confidence < 0.9 },
]}
>
Resolve this ticket: {ctx.input.ticketBody}
</EscalationChain>
</Workflow>
Notes
- Each level uses
continueOnFail; failures propagate to the next level. escalateIfevaluates at render time: as each level’s output becomes available, the chain re-renders and calls the predicate to decide whether the next level mounts.
Source
The<EscalationChain> implementation and the files it imports, straight from the package source. This section is generated; edit the source, not this block.
// @smithers-type-exports-begin
/** @typedef {import("./EscalationChainProps.ts").EscalationChainProps} EscalationChainProps */
/** @typedef {import("./EscalationLevel.ts").EscalationLevel} EscalationLevel */
// @smithers-type-exports-end
import React from "react";
import { SmithersContext } from "@smthrs/react-reconciler/context";
import { Sequence } from "./Sequence.js";
import { Branch } from "./Branch.js";
import { Task } from "./Task.js";
import { Approval } from "./Approval.js";
/**
* Default escalation predicate: escalate when the previous level has no result
* yet, or its result signals a failure (`error`/`failed` truthy or `ok === false`).
* @param {unknown} result
* @returns {boolean}
*/
function defaultEscalateIf(result) {
if (result == null) return true;
if (typeof result === "object") {
const row = /** @type {Record<string, unknown>} */ (result);
if (row.error != null && row.error !== false) return true;
if (row.failed === true) return true;
if (row.ok === false) return true;
}
return false;
}
/**
* Resolve whether the previous level escalated by invoking its `escalateIf`
* predicate (or the default) against its actual result.
* @param {EscalationLevel} prevLevel
* @param {unknown} prevResult
* @returns {boolean}
*/
function didEscalate(prevLevel, prevResult) {
const predicate = prevLevel.escalateIf ?? defaultEscalateIf;
return Boolean(predicate(prevResult));
}
/**
* Escalation chain: tries agents in order, escalating on failure or when
* `escalateIf` returns `true`. Optionally ends with a human approval fallback.
*
* Composes Sequence + Task (with `continueOnFail`) + Branch + Approval.
* @param {EscalationChainProps} props
*/
export function EscalationChain(props) {
if (props.skipIf) return null;
const ctx = React.useContext(SmithersContext);
const prefix = props.id ?? "escalation";
const { levels, children, humanFallback, humanRequest, escalationOutput } = props;
// Build the chain from the last level forward, nesting each level inside a
// Branch that gates on the previous level's escalation condition.
// We construct the elements bottom-up so the final element is a single
// Sequence that evaluates top-down at runtime.
const levelElements = [];
for (let i = 0; i < levels.length; i++) {
const level = levels[i];
const levelId = `${prefix}-level-${i}`;
const isFirst = i === 0;
const taskEl = React.createElement(Task, {
id: levelId,
output: level.output,
agent: level.agent,
continueOnFail: true,
label: level.label ?? `Escalation level ${i}`,
children: children,
});
if (isFirst) {
// First level always runs.
levelElements.push(taskEl);
} else {
// Subsequent levels are gated by a Branch that checks whether the
// previous level needs escalation. The chain re-renders reactively as
// outputs become available, so we read the previous level's actual
// result from the workflow context and run its `escalateIf` predicate
// (or the default failure predicate) to decide whether this level runs.
const prevLevel = levels[i - 1];
const prevLevelId = `${prefix}-level-${i - 1}`;
const prevResult = ctx?.outputMaybe(prevLevel.output, { nodeId: prevLevelId });
const escalated = didEscalate(prevLevel, prevResult);
const checkId = `${prefix}-check-${i - 1}`;
const checkTask = React.createElement(Task, {
id: checkId,
output: escalationOutput,
continueOnFail: true,
label: `Check escalation from level ${i - 1}`,
children: () => {
// Record the escalation decision for the prior level so it is
// visible in the escalation output stream.
return {
escalated,
fromLevel: i - 1,
toLevel: i,
};
},
});
// Gate the current level on the previous level's escalation decision:
// it only mounts when the prior level actually escalated.
const gatedLevel = React.createElement(Branch, {
if: escalated,
then: taskEl,
});
levelElements.push(checkTask);
levelElements.push(gatedLevel);
}
}
// Append human fallback if requested. It only mounts when every automated
// level escalated (i.e. all automated levels were exhausted). A single
// level resolving without escalation stops the chain and the fallback, even
// if later levels never ran and therefore have no recorded result.
if (humanFallback && levels.length > 0) {
const humanId = `${prefix}-human-fallback`;
const request = humanRequest ?? {
title: "Escalation requires human review",
summary: `All ${levels.length} automated levels have been exhausted.`,
};
const allEscalated = levels.every((level, idx) => {
const levelResult = ctx?.outputMaybe(level.output, {
nodeId: `${prefix}-level-${idx}`,
});
return didEscalate(level, levelResult);
});
const approvalEl = React.createElement(Approval, {
id: humanId,
output: escalationOutput,
request,
onDeny: "continue",
continueOnFail: true,
label: request.title,
});
levelElements.push(
React.createElement(Branch, {
if: allEscalated,
then: approvalEl,
}),
);
}
return React.createElement(Sequence, {}, ...levelElements);
}
// @smithers-type-exports-begin
/** @typedef {import("./OutputSnapshot.ts").OutputSnapshot} OutputSnapshot */
/** @typedef {import("./SmithersCtxOptions.ts").SmithersCtxOptions} SmithersCtxOptions */
// @smithers-type-exports-end
import React from "react";
import { SmithersError } from "@smthrs/errors/SmithersError";
export { SmithersCtx } from "@smthrs/driver/SmithersCtx";
/** @type {React.Context<SmithersCtx<any> | null>} */
export const SmithersContext = React.createContext(null);
SmithersContext.displayName = "SmithersContext";
/**
* @template Schema
* @returns {{ SmithersContext: React.Context<SmithersCtx<Schema> | null>, useCtx: () => SmithersCtx<Schema> }}
*/
export function createSmithersContext() {
/** @type {React.Context<SmithersCtx<Schema> | null>} */
const Context = React.createContext(null);
Context.displayName = "SmithersContext";
/**
* @returns {SmithersCtx<Schema>}
*/
function useCtx() {
const ctx = React.useContext(Context);
if (!ctx) {
throw new SmithersError(
"CONTEXT_OUTSIDE_WORKFLOW",
"useCtx() must be called inside a <Workflow> created by createSmithers()",
);
}
return ctx;
}
return { SmithersContext: Context, useCtx };
}
import React from "react";
/** @typedef {import("./SequenceProps.ts").SequenceProps} SequenceProps */
/**
* @param {SequenceProps} props
*/
export function Sequence(props) {
if (props.skipIf) return null;
// Sequence carries only a display label; pass a sanitized bag (align with
// the sanitizing structural components) so control props don't leak through.
// `label` names the phase group in run views (graph, the Claude /workflows
// mirror) and is preserved in the persisted frame XML.
const next = {
...(props.label === undefined ? {} : { label: props.label }),
...(props.failurePolicy === undefined ? {} : { failurePolicy: props.failurePolicy }),
};
return React.createElement("smithers:sequence", next, props.children);
}
import React from "react";
import { SmithersError } from "@smthrs/errors/SmithersError";
/** @typedef {import("./BranchProps.ts").BranchProps} BranchProps */
/**
* @param {BranchProps} props
*/
export function Branch(props) {
// <Branch> resolves its subtree from the `then`/`else` props; any JSX children
// would be silently dropped, removing those tasks from the graph with no
// feedback. Fail fast instead. (Checked before skipIf so a stray-children
// mistake still surfaces even on a skipped branch.)
if (props.children !== undefined && props.children !== null) {
throw new SmithersError(
"INVALID_INPUT",
`<Branch> does not take children. Use the "then" and "else" props instead, e.g. ` +
`<Branch if={cond} then={<Task .../>} else={<Task .../>} />. ` +
`Children passed to <Branch> are silently ignored and would drop those tasks from the graph.`,
);
}
if (props.skipIf) return null;
const chosen = props.if ? props.then : (props.else ?? null);
// The branch is resolved to `chosen` at render time, so the host element
// carries no props of its own (align with the sanitizing structural components).
return React.createElement("smithers:branch", {}, chosen);
}
// @smithers-type-exports-begin
/**
* @template D
* @typedef {import("./InferDeps.ts").InferDeps<D>} InferDeps
*/
/** @typedef {import("./OutputTarget.ts").OutputTarget} OutputTarget */
// @smithers-type-exports-end
import { applyCliToolAllowlist } from "./cliToolAllowlist.js";
import { createTaskComponent } from "./taskCore.js";
export { renderPromptToText } from "./taskCore.js";
/**
* The Node/CLI-agent-aware `Task`. Every render-path behavior (deps
* resolution, agent-chain assembly, MDX prompt rendering, static/compute/agent
* branching) lives in `taskCore.js`; this file only supplies the CLI-agent
* tool-allowlist enforcement step (`applyCliToolAllowlist`, which statically
* imports `ClaudeCodeAgent`/`PiAgent`/`GeminiAgent`/`AntigravityAgent` — see
* `cliToolAllowlist.js`). `Task.browser.js` builds the same component with a
* no-op allowlist step instead, so it never pulls those Node-only
* (`node:child_process`-backed) classes into a browser bundle.
*/
export const Task = createTaskComponent({ applyCliToolAllowlist });
// @smithers-type-exports-begin
/** @typedef {import("./ApprovalDecision.ts").ApprovalDecision} ApprovalDecision */
/** @typedef {import("./ApprovalRanking.ts").ApprovalRanking} ApprovalRanking */
/** @typedef {import("./ApprovalRequest.ts").ApprovalRequest} ApprovalRequest */
/** @typedef {import("./ApprovalSelection.ts").ApprovalSelection} ApprovalSelection */
// @smithers-type-exports-end
import React from "react";
import { z } from "zod";
import { SmithersContext } from "@smthrs/react-reconciler/context";
import { getTaskRuntime } from "@smthrs/driver/task-runtime";
import { SmithersDb } from "@smthrs/db/adapter";
import { SmithersError } from "@smthrs/errors/SmithersError";
/** @typedef {import("./ApprovalAutoApprove.ts").ApprovalAutoApprove} ApprovalAutoApprove */
/** @typedef {import("./ApprovalMode.ts").ApprovalMode} ApprovalMode */
/** @typedef {import("./ApprovalOption.ts").ApprovalOption} ApprovalOption */
/**
* @template Row, Output
* @typedef {import("./ApprovalProps.ts").ApprovalProps<Row, Output>} ApprovalProps
*/
export const approvalDecisionSchema = z.object({
approved: z.boolean(),
// `note` is omitted entirely when no note was provided, so the default
// decision schema must accept an absent key (optional) as well as the
// legacy null/string shapes.
note: z.string().nullable().optional(),
decidedBy: z.string().nullable(),
decidedAt: z.string().datetime().nullable(),
});
export const approvalSelectionSchema = z.object({
selected: z.string(),
notes: z.string().nullable(),
});
export const approvalRankingSchema = z.object({
ranked: z.array(z.string()),
notes: z.string().nullable(),
});
/**
* @param {unknown} value
* @returns {value is import("zod").ZodObject<import("zod").ZodRawShape>}
*/
function isZodObject(value) {
return Boolean(value && typeof value === "object" && "shape" in value);
}
/**
* @template T
* @param {unknown} value
* @returns {T | null}
*/
function parseJson(value) {
if (typeof value !== "string" || value.length === 0) {
return null;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
/**
* @param {ApprovalMode} mode
* @returns {import("zod").ZodObject<import("zod").ZodRawShape>}
*/
function defaultSchemaForMode(mode) {
switch (mode) {
case "select":
return approvalSelectionSchema;
case "rank":
return approvalRankingSchema;
default:
return approvalDecisionSchema;
}
}
/**
* @param {{ status?: string | null; note?: string | null; decidedBy?: string | null; decidedAtMs?: number | null } | undefined | null} approval
* @param {import("zod").ZodObject<import("zod").ZodRawShape>} outputSchema
* @returns {Record<string, unknown>}
*/
function buildDecisionPayload(approval, outputSchema) {
const base = {
approved: approval?.status === "approved",
decidedBy: approval?.decidedBy ?? null,
decidedAt: approval?.decidedAtMs != null ? new Date(approval.decidedAtMs).toISOString() : null,
};
if (typeof approval?.note === "string") {
return { ...base, note: approval.note };
}
if (outputSchema.safeParse(base).success) {
return base;
}
return { ...base, note: null };
}
/**
* @param {ApprovalMode | undefined} mode
* @returns {"select" | "rank" | "decision"}
*/
function normalizeMode(mode) {
switch (mode) {
case "select":
return "select";
case "rank":
return "rank";
default:
return "decision";
}
}
/**
* @param {ApprovalOption[] | undefined} options
* @returns {ApprovalOption[] | undefined}
*/
function normalizeOptions(options) {
return options?.map((option) => ({
key: option.key,
label: option.label,
...(option.summary ? { summary: option.summary } : {}),
...(option.metadata ? { metadata: option.metadata } : {}),
}));
}
/**
* @param {unknown} value
* @param {"allowedScopes" | "allowedUsers"} field
* @param {string} id
* @returns {string[] | undefined}
*/
function validateApprovalRestriction(value, field, id) {
if (value === undefined) {
return undefined;
}
if (
!Array.isArray(value) ||
Array.from(value).some((entry) => typeof entry !== "string" || entry.trim().length === 0)
) {
throw new SmithersError("INVALID_INPUT", `Approval ${id} ${field} must be an array of non-empty strings.`);
}
return value;
}
/**
* @param {ApprovalAutoApprove[keyof ApprovalAutoApprove]} callback
* @param {import("@smthrs/driver").SmithersCtx<unknown> | null} ctx
* @returns {boolean | undefined}
*/
function evaluateBooleanCallback(callback, ctx) {
if (typeof callback !== "function") {
return undefined;
}
return Boolean(/** @type {(ctx: import("@smthrs/driver").SmithersCtx<unknown> | null) => boolean} */ (callback)(ctx));
}
/**
* @template Row
* @param {ApprovalProps<Row>} props
* @returns {React.ReactElement | null}
*/
export function Approval(props) {
if (props.skipIf) return null;
const smithersContext = props.smithersContext ?? SmithersContext;
const ctx = React.useContext(smithersContext);
const mode = props.mode ?? "approve";
const approvalMode = normalizeMode(mode);
const options = normalizeOptions(props.options);
const allowedScopes = validateApprovalRestriction(props.allowedScopes, "allowedScopes", props.id);
const allowedUsers = validateApprovalRestriction(props.allowedUsers, "allowedUsers", props.id);
const outputSchema = props.outputSchema ?? (isZodObject(props.output) ? props.output : defaultSchemaForMode(mode));
if ((mode === "select" || mode === "rank") && (!options || options.length === 0)) {
throw new SmithersError("APPROVAL_OPTIONS_REQUIRED", `Approval ${props.id} requires options when mode="${mode}".`);
}
const conditionMet = props.autoApprove ? evaluateBooleanCallback(props.autoApprove.condition, ctx) : undefined;
const revertOnMet = props.autoApprove ? evaluateBooleanCallback(props.autoApprove.revertOn, ctx) : undefined;
const autoApprove = props.autoApprove
? {
...(typeof props.autoApprove.after === "number" ? { after: props.autoApprove.after } : {}),
audit: props.autoApprove.audit !== false,
...(conditionMet !== undefined ? { conditionMet } : {}),
...(revertOnMet !== undefined ? { revertOnMet } : {}),
}
: undefined;
const requestMeta = {
requestTitle: props.request.title,
...(props.request.summary ? { requestSummary: props.request.summary } : {}),
...(options ? { approvalOptions: options } : {}),
...(allowedScopes?.length ? { approvalAllowedScopes: allowedScopes } : {}),
...(allowedUsers?.length ? { approvalAllowedUsers: allowedUsers } : {}),
...(autoApprove ? { approvalAutoApprove: autoApprove } : {}),
...props.request.metadata,
...props.meta,
};
/**
* @returns {Promise<Row>}
*/
const computeDecision = async () => {
const runtime = getTaskRuntime();
if (!runtime) {
throw new SmithersError(
"APPROVAL_OUTSIDE_TASK",
"Approval decisions can only be resolved while a Smithers task is executing.",
);
}
const adapter = new SmithersDb(runtime.db);
const approval = await adapter.getApproval(runtime.runId, props.id, runtime.iteration);
const decision = parseJson(approval?.decisionJson);
if (approvalMode === "select") {
return {
selected: typeof decision?.selected === "string" ? decision.selected : "",
notes: typeof decision?.notes === "string" ? decision.notes : (approval?.note ?? null),
};
}
if (approvalMode === "rank") {
return {
ranked: Array.isArray(decision?.ranked) ? decision.ranked.filter((value) => typeof value === "string") : [],
notes: typeof decision?.notes === "string" ? decision.notes : (approval?.note ?? null),
};
}
return buildDecisionPayload(approval, outputSchema);
};
return React.createElement("smithers:task", {
id: props.id,
output: props.output,
outputSchema,
dependsOn: props.dependsOn,
needs: props.needs,
...(Object.hasOwn(props, "bind") ? { bind: props.bind } : {}),
needsApproval: true,
waitAsync: props.async === true,
approvalMode,
approvalOnDeny: props.onDeny ?? "fail",
approvalOptions: options,
approvalAllowedScopes: allowedScopes,
approvalAllowedUsers: allowedUsers,
approvalAutoApprove: autoApprove,
timeoutMs: props.timeoutMs,
heartbeatTimeoutMs: props.heartbeatTimeoutMs,
heartbeatTimeout: props.heartbeatTimeout,
retries: props.retries,
retryPolicy: props.retryPolicy,
continueOnFail: props.continueOnFail,
cache: props.cache,
label: props.label ?? props.request.title,
meta: Object.keys(requestMeta).length > 0 ? requestMeta : undefined,
__smithersKind: "compute",
__smithersComputeFn: computeDecision,
});
}
import type React from "react";
import type { ApprovalRequest } from "./ApprovalRequest.ts";
import type { EscalationLevel } from "./EscalationLevel.ts";
import type { OutputTarget } from "./OutputTarget.ts";
export type EscalationChainProps = {
/** ID prefix for generated nodes. */
id?: string;
/** Ordered escalation levels. Each level runs only if the previous escalated. */
levels: EscalationLevel[];
/** If `true`, the final escalation produces a human approval node. */
humanFallback?: boolean;
/** Approval request config used when `humanFallback` is `true`. */
humanRequest?: ApprovalRequest;
/** Output target for escalation tracking at each level. */
escalationOutput: OutputTarget;
skipIf?: boolean;
/** Prompt / input passed to each agent level. */
children?: React.ReactNode;
};
import type { AgentLike } from "@smthrs/agents/AgentLike";
import type { OutputTarget } from "./OutputTarget.ts";
export type EscalationLevel = {
/** Agent to handle this escalation level. */
agent: AgentLike;
/** Output target for this level's result. */
output: OutputTarget;
/** Display label for this level. */
label?: string;
/** Predicate evaluated on the level's result. Return `true` to escalate. */
escalateIf?: (result: unknown) => boolean;
};