---
title: <EscalationChain>
description: Sequential agent escalation with optional human fallback.
---

```ts
// Props
import { EscalationChain } from "smithers-orchestrator";

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
};
```

```tsx
<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.
- `escalateIf` is evaluated at render time. The chain re-renders reactively as each level's output becomes available and calls the predicate to decide whether the next level mounts.

{/* GENERATED:COMPONENT-SOURCE START (regenerate: pnpm docs:components) */}
## Source

The `<EscalationChain>` implementation and the files it imports, straight from the package source. This section is generated; edit the source, not this block.

<CodeGroup>
```js EscalationChain.js
// @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 "@smithers-orchestrator/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,
            continueOnFail: true,
            label: request.title,
        });
        levelElements.push(React.createElement(Branch, {
            if: allEscalated,
            then: approvalEl,
        }));
    }
    return React.createElement(Sequence, {}, ...levelElements);
}
```

```js context.js
// @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 { SmithersCtx } from "@smithers-orchestrator/driver/SmithersCtx";
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
export { SmithersCtx } from "@smithers-orchestrator/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 };
}
```

```js Sequence.js
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 };
    return React.createElement("smithers:sequence", next, props.children);
}
```

```js Branch.js
import React from "react";
import { SmithersError } from "@smithers-orchestrator/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);
}
```

```js Task.js
// @smithers-type-exports-begin
/**
 * @template D
 * @typedef {import("./InferDeps.ts").InferDeps<D>} InferDeps
 */
/** @typedef {import("./OutputTarget.ts").OutputTarget} OutputTarget */
// @smithers-type-exports-end

import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { markdownComponents } from "../markdownComponents.js";
import { zodSchemaToJsonExample } from "../zod-to-example.js";
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
import { AspectContext } from "../aspects/AspectContext.js";
import { AntigravityAgent } from "@smithers-orchestrator/agents/AntigravityAgent";
import { ClaudeCodeAgent } from "@smithers-orchestrator/agents/ClaudeCodeAgent";
import { GeminiAgent } from "@smithers-orchestrator/agents/GeminiAgent";
import { PiAgent } from "@smithers-orchestrator/agents/PiAgent";
/** @typedef {import("@smithers-orchestrator/agents/AgentLike").AgentLike} AgentLike */
/** @typedef {import("./DepsSpec.ts").DepsSpec} DepsSpec */
/**
 * @template Row, Output, D
 * @typedef {import("./TaskProps.ts").TaskProps<Row, Output, D>} TaskProps
 */

/**
 * Reverse the HTML-entity escaping that renderToStaticMarkup applies to text
 * (& < > " '). Without this, a prompt like `a < b && c` reaches the agent as
 * `a &lt; b &amp;&amp; c`, and injected JSON schema hints (`"key"`) arrive as
 * `&quot;key&quot;`, both of which corrupt the agent-facing text.
 * `&amp;` is decoded last so an escaped literal (`&amp;lt;`, meaning the source
 * text `&lt;`) is not double-decoded into `<`.
 * @param {string} html
 * @returns {string}
 */
function decodeHtmlEntities(html) {
    return html
        .replace(/&lt;/g, "<")
        .replace(/&gt;/g, ">")
        .replace(/&quot;/g, '"')
        .replace(/&#x27;/g, "'")
        .replace(/&#39;/g, "'")
        .replace(/&amp;/g, "&");
}
/**
 * Render a prompt React node to plain markdown text.
 *
 * If the prompt is a React element (e.g. a compiled MDX component), we inject
 * `markdownComponents` via the standard MDX `components` prop so that
 * renderToStaticMarkup outputs clean markdown instead of HTML. The static
 * render entity-escapes all text, so we decode the entities back to literal
 * characters before handing the prompt to the agent.
 * @param {unknown} prompt
 * @returns {string}
 */
export function renderPromptToText(prompt) {
    if (prompt == null)
        return "";
    if (typeof prompt === "string")
        return prompt;
    if (typeof prompt === "number")
        return String(prompt);
    try {
        let element;
        if (React.isValidElement(prompt)) {
            // Inject markdown components into the element so MDX components
            // render fragments instead of HTML tags.
            element = React.cloneElement(prompt, {
                components: markdownComponents,
            });
        }
        else {
            element = React.createElement(React.Fragment, null, prompt);
        }
        return decodeHtmlEntities(renderToStaticMarkup(element))
            .replace(/\n{3,}/g, "\n\n")
            .trim();
    }
    catch (err) {
        const result = String(prompt ?? "");
        if (result === "[object Object]") {
            throw new SmithersError("MDX_PRELOAD_INACTIVE", `MDX prompt could not be rendered — the prompt resolved to [object Object] instead of a React component.\n\n` +
                `This usually means the MDX preload is not active. Common causes:\n` +
                `  • bunfig.toml uses [run] preload instead of top-level preload (the [run] section doesn't apply to dynamic imports)\n` +
                `  • bunfig.toml is not in the current working directory\n` +
                `  • mdxPlugin() is not registered in the preload script\n` +
                `  • The MDX file is imported without a default import (use: import MyPrompt from "./prompt.mdx")\n\n` +
                `Original error: ${err instanceof Error ? err.message : String(err)}`);
        }
        return result;
    }
}
/**
 * @param {unknown} value
 * @returns {value is import("zod").ZodObject<import("zod").ZodRawShape>}
 */
function isZodObject(value) {
    return Boolean(value && typeof value === "object" && "shape" in value);
}
/**
 * @param {DepsSpec | undefined} deps
 * @param {Record<string, string> | undefined} needs
 * @returns {string[] | undefined}
 */
function deriveDepNodeIds(deps, needs) {
    if (!deps)
        return undefined;
    const ids = new Set();
    for (const key of Object.keys(deps)) {
        const nodeId = needs?.[key] ?? key;
        if (nodeId)
            ids.add(nodeId);
    }
    return ids.size > 0 ? [...ids] : undefined;
}
/**
 * @param {string[] | undefined} dependsOn
 * @param {string[] | undefined} depNodeIds
 * @returns {string[] | undefined}
 */
function mergeDependsOn(dependsOn, depNodeIds) {
    const merged = new Set();
    for (const id of dependsOn ?? [])
        merged.add(id);
    for (const id of depNodeIds ?? [])
        merged.add(id);
    return merged.size > 0 ? [...merged] : undefined;
}
/**
 * @param {any} ctx
 * @param {DepsSpec | undefined} deps
 * @param {Record<string, string> | undefined} needs
 * @param {boolean | undefined} depsOptional
 * @returns {Record<string, unknown> | null}
 */
function resolveDeps(ctx, deps, needs, depsOptional) {
    if (!deps)
        return Object.create(null);
    const keys = Object.keys(deps);
    if (keys.length === 0)
        return Object.create(null);
    const resolved = Object.create(null);
    for (const key of keys) {
        const target = deps[key];
        const nodeId = needs?.[key] ?? key;
        const value = ctx.outputMaybe(target, { nodeId });
        if (value === undefined) {
            // Optional deps mode: omit unresolved keys instead of deferring the
            // whole task. Used when the task is already gated by `needs`/`dependsOn`
            // and upstream tasks may legitimately fail (continueOnFail) without
            // producing an output row.
            if (depsOptional)
                continue;
            return null;
        }
        resolved[key] = value;
    }
    return resolved;
}
/**
 * @param {AgentLike} agent
 * @param {string[] | undefined} allowTools
 * @returns {AgentLike}
 */
function applyCliToolAllowlist(agent, allowTools) {
    if (!allowTools) {
        return agent;
    }
    if (agent instanceof ClaudeCodeAgent) {
        const opts = { ...agent.opts };
        if (allowTools.length === 0) {
            return new ClaudeCodeAgent({
                ...opts,
                allowedTools: [],
                tools: "",
            });
        }
        return new ClaudeCodeAgent({
            ...opts,
            allowedTools: [...allowTools],
        });
    }
    if (agent instanceof PiAgent) {
        const opts = { ...agent.opts };
        if (allowTools.length === 0) {
            return new PiAgent({
                ...opts,
                tools: [],
                noTools: true,
            });
        }
        return new PiAgent({
            ...opts,
            tools: [...allowTools],
            noTools: false,
        });
    }
    if (agent instanceof GeminiAgent) {
        const opts = { ...agent.opts };
        return new GeminiAgent({
            ...opts,
            allowedTools: [...allowTools],
        });
    }
    if (agent instanceof AntigravityAgent) {
        const opts = { ...agent.opts };
        return new AntigravityAgent({
            ...opts,
            allowedTools: [...allowTools],
        });
    }
    return agent;
}
/**
 * @param {unknown} ctx
 * @param {string[] | undefined} allowTools
 * @returns {string[] | undefined}
 */
function resolveCliToolAllowlist(ctx, allowTools) {
    if (allowTools !== undefined) {
        return allowTools;
    }
    const cliAgentToolsDefault = ctx && typeof ctx === "object"
        ? ctx.__smithersRuntime?.cliAgentToolsDefault
        : undefined;
    return cliAgentToolsDefault === "explicit-only" ? [] : undefined;
}
/**
 * @template Row, Output, D
 * @param {TaskProps<Row, Output, D>} props
 * @returns {React.ReactElement | null}
 */
export function Task(props) {
    const { children, agent, fallbackAgent, deps, depsOptional, ...rest } = props;
    const taskContext = props.smithersContext ?? SmithersContext;
    const ctx = React.useContext(taskContext);
    const aspectCtx = React.useContext(AspectContext);
    const depNodeIds = deriveDepNodeIds(deps, rest.needs);
    if (deps && !ctx) {
        throw new SmithersError("CONTEXT_OUTSIDE_WORKFLOW", "Task deps require a workflow context. Build the workflow with createSmithers().");
    }
    const resolvedDeps = deps ? resolveDeps(ctx, deps, rest.needs, depsOptional) : undefined;
    if (deps && resolvedDeps == null) {
        // Deps not yet available — component defers until upstream tasks complete.
        // This is normal reactive behavior; the task will re-render once deps are
        // ready. Record the deferral so the engine can distinguish a transient wait
        // from a permanent one: a deferral that survives to quiescence means a
        // dependency that can never resolve (e.g. a deps key that maps to a node id
        // no task produces), which would otherwise be a silent skip.
        ctx?.recordDeferredDep?.(props.id, depNodeIds ?? []);
        return null;
    }
    // Build aspect metadata to attach to the task element so the engine can
    // enforce budgets and track metrics at execution time.
    const aspectMeta = aspectCtx ? buildAspectMeta(aspectCtx) : undefined;
    const agentChain = Array.isArray(agent)
        ? fallbackAgent
            ? [...agent, fallbackAgent]
            : agent
        : agent && fallbackAgent
            ? [agent, fallbackAgent]
            : agent;
    const effectiveAllowTools = resolveCliToolAllowlist(ctx, rest.allowTools);
    const restrictedAgentChain = Array.isArray(agentChain)
        ? agentChain.map((entry) => applyCliToolAllowlist(entry, effectiveAllowTools))
        : agentChain
            ? applyCliToolAllowlist(agentChain, effectiveAllowTools)
            : agentChain;
    const nextDependsOn = mergeDependsOn(rest.dependsOn, depNodeIds);
    const childValue = typeof children === "function" && (agent || deps)
        ? children(resolvedDeps ?? Object.create(null))
        : children;
    if (agent) {
        // Auto-inject `schema` prop into React element children when output is a ZodObject
        let childElement = childValue;
        const schemaForInjection = props.outputSchema ??
            (isZodObject(props.output) ? props.output : undefined);
        if (React.isValidElement(childValue) && schemaForInjection) {
            childElement = React.cloneElement(childValue, {
                schema: zodSchemaToJsonExample(schemaForInjection),
            });
        }
        const prompt = renderPromptToText(childElement);
        return React.createElement("smithers:task", {
            ...rest,
            dependsOn: nextDependsOn,
            waitAsync: rest.async === true,
            agent: restrictedAgentChain,
            __smithersKind: "agent",
            ...aspectMeta,
        }, prompt);
    }
    if (typeof children === "function" && !deps) {
        const nextProps = {
            ...rest,
            dependsOn: nextDependsOn,
            waitAsync: rest.async === true,
            __smithersKind: "compute",
            __smithersComputeFn: children,
            ...aspectMeta,
        };
        return React.createElement("smithers:task", nextProps, null);
    }
    const nextProps = {
        ...rest,
        dependsOn: nextDependsOn,
        waitAsync: rest.async === true,
        __smithersKind: "static",
        __smithersPayload: childValue,
        __payload: childValue,
        ...aspectMeta,
    };
    return React.createElement("smithers:task", nextProps, null);
}
/**
 * Build the __aspects metadata object from the current AspectContext.
 * This is attached to the smithers:task element props so the engine can read
 * budgets and tracking config at execution time.
 * @param {{
 *     tokenBudget?: unknown;
 *     latencySlo?: unknown;
 *     tracking?: unknown;
 *     accumulator?: unknown;
 * }} aspectCtx
 * @returns {{ __aspects: Record<string, unknown> }}
 */
function buildAspectMeta(aspectCtx) {
    return {
        __aspects: {
            tokenBudget: aspectCtx.tokenBudget,
            latencySlo: aspectCtx.latencySlo,
            tracking: aspectCtx.tracking,
            accumulator: aspectCtx.accumulator,
        },
    };
}
```

```js Approval.js
// @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 "@smithers-orchestrator/react-reconciler/context";
import { getTaskRuntime } from "@smithers-orchestrator/driver/task-runtime";
import { SmithersDb } from "@smithers-orchestrator/db/adapter";
import { SmithersError } from "@smithers-orchestrator/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 {ApprovalAutoApprove[keyof ApprovalAutoApprove]} callback
 * @param {import("@smithers-orchestrator/driver").SmithersCtx<unknown> | null} ctx
 * @returns {boolean | undefined}
 */
function evaluateBooleanCallback(callback, ctx) {
    if (typeof callback !== "function") {
        return undefined;
    }
    return Boolean(/** @type {(ctx: import("@smithers-orchestrator/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 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 } : {}),
        ...(props.allowedScopes?.length ? { approvalAllowedScopes: props.allowedScopes } : {}),
        ...(props.allowedUsers?.length ? { approvalAllowedUsers: props.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,
        key: props.key,
        output: props.output,
        outputSchema,
        dependsOn: props.dependsOn,
        needs: props.needs,
        needsApproval: true,
        waitAsync: props.async === true,
        approvalMode,
        approvalOnDeny: props.onDeny,
        approvalOptions: options,
        approvalAllowedScopes: props.allowedScopes,
        approvalAllowedUsers: props.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,
    });
}
```

```ts EscalationChainProps.ts
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;
};
```

```ts EscalationLevel.ts
import type { AgentLike } from "@smithers-orchestrator/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;
};
```
</CodeGroup>
{/* GENERATED:COMPONENT-SOURCE END */}
