// Props
import { Panel } from "smithers-orchestrator";
// agent may be a single agent or a failover chain (AgentLike[]) run as one panelist.
type PanelistConfig = { agent: AgentLike | AgentLike[]; role?: string; label?: string };
type PanelTaskOptions = { continueOnFail?: boolean; timeoutMs?: number; heartbeatTimeoutMs?: number; retries?: number };
type PanelProps = {
id?: string; // default: "panel"
// each entry: an agent, a PanelistConfig, or a failover chain (AgentLike[])
panelists: Array<PanelistConfig | AgentLike | AgentLike[]>;
moderator: AgentLike | AgentLike[]; // a chain runs as failover
panelistOutput: OutputTarget;
moderatorOutput: OutputTarget;
strategy?: "synthesize" | "vote" | "consensus"; // default: "synthesize"
minAgree?: number; // for "vote" / "consensus"
maxConcurrency?: number; // default: Infinity
panelistTaskProps?: PanelTaskOptions; // extra Task props for each panelist
moderatorTaskProps?: PanelTaskOptions; // extra Task props for the moderator
skipIf?: boolean;
children: string | ReactNode; // prompt sent to every panelist
};
<Workflow name="code-review-panel">
<Panel
panelists={[
{ agent: securityAgent, role: "Security Reviewer" },
{ agent: qualityAgent, role: "Code Quality Reviewer" },
{ agent: architectureAgent, role: "Architecture Reviewer" },
]}
moderator={moderatorAgent}
panelistOutput={outputs.review}
moderatorOutput={outputs.synthesis}
>
Review the changes in src/auth/ for security, quality, and architecture concerns.
</Panel>
</Workflow>
Notes
- Panelist task ids:
{prefix}-{label|role|panelist-N}; moderator is{prefix}-moderator. strategyandminAgreeare passed as prompt context to the moderator, which interprets them.- All panelists write to the same
panelistOutputschema, differentiated by task id.
Source
The<Panel> 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("./PanelProps.ts").PanelProps} PanelProps */
// @smithers-type-exports-end
import React from "react";
import { Sequence } from "./Sequence.js";
import { Parallel } from "./Parallel.js";
import { Task } from "./Task.js";
/** @typedef {import("@smithers-orchestrator/agents/AgentLike").AgentLike} AgentLike */
/** @typedef {import("./PanelistConfig.ts").PanelistConfig} PanelistConfig */
/**
* @param {PanelistConfig | AgentLike | AgentLike[]} entry
* @param {number} index
* @returns {PanelistConfig}
*/
function normalizePanelist(entry, index) {
// A failover chain (AgentLike[]) is one panelist whose agent IS the chain —
// the Task runs the chain as a failover sequence. Without this, an array
// entry falls through to the PanelistConfig branch and `p.agent` is undefined.
if (Array.isArray(entry)) {
return { agent: entry, label: `panelist-${index}` };
}
if ("generate" in entry && !("agent" in entry)) {
return { agent: entry, label: `panelist-${index}` };
}
return entry;
}
/**
* <Panel> — Parallel specialists review the same input, then a moderator synthesizes.
*
* Composes: Sequence > Parallel[Task per panelist] > Task(moderator)
* @param {PanelProps} props
*/
export function Panel(props) {
if (props.skipIf)
return null;
const { id, panelists, moderator, panelistOutput, moderatorOutput, strategy = "synthesize", minAgree, maxConcurrency, panelistTaskProps, moderatorTaskProps, children, } = props;
if (!Array.isArray(panelists) || panelists.length === 0) {
throw new Error("Panel panelists must include at least one panelist.");
}
const prefix = id ?? "panel";
const normalized = panelists.map(normalizePanelist);
// Single source of the panelist task ids: the tasks, needs, and deps maps
// below all key off these, so the derivation can never drift.
const taskIds = normalized.map((p, i) => `${prefix}-${p.label ?? p.role ?? `panelist-${i}`}`);
// Build parallel panelist tasks
const panelistTasks = normalized.map((p, i) => {
const taskId = taskIds[i];
return React.createElement(Task, {
key: taskId,
id: taskId,
output: panelistOutput,
agent: p.agent,
label: p.role ?? p.label,
...panelistTaskProps,
children,
});
});
const parallelEl = React.createElement(Parallel, { maxConcurrency }, ...panelistTasks);
// Build needs map: each panelist task id -> its task id. This gates the
// moderator (via dependsOn) until every panelist node is terminal
// (finished OR failed), regardless of whether its output resolves.
const needs = {};
taskIds.forEach((taskId) => {
needs[taskId] = taskId;
});
// Build deps map (same keys as needs) so the moderator's prompt can be
// built from each panelist's resolved output. `depsOptional` means a
// panelist that failed (continueOnFail, no output row) is simply omitted
// from the prompt rather than deferring the moderator forever.
const deps = {};
taskIds.forEach((taskId) => {
deps[taskId] = panelistOutput;
});
// Moderator prompt includes strategy metadata
const strategyPrompt = strategy === "vote"
? `\n\nStrategy: VOTE. Count how many panelists agree. ${minAgree ? `Minimum agreement required: ${minAgree}.` : ""}`
: strategy === "consensus"
? `\n\nStrategy: CONSENSUS. All panelists must converge. ${minAgree ? `Minimum agreement required: ${minAgree}.` : ""}`
: `\n\nStrategy: SYNTHESIZE. Combine all panelist outputs into a single coherent result. Preserve each panelist's concrete, grounded findings verbatim (specific file paths, line numbers, identifiers, prior-PR references, and what already exists); reconcile disagreements with evidence. Do not over-generalize, drop specifics, or change the scope the panelists analyzed.`;
const moderatorChildren = (panelistOutputs) => {
const panelistIds = Object.keys(deps);
const outputsText = panelistIds
.map((taskId) => {
if (!(taskId in panelistOutputs))
return `### ${taskId}\n(no output — this panelist failed)`;
return `### ${taskId}\n${JSON.stringify(panelistOutputs[taskId])}`;
})
.join("\n\n");
return `Synthesize the following panelist outputs.\n\n${outputsText}${strategyPrompt}`;
};
const moderatorTask = React.createElement(Task, {
id: `${prefix}-moderator`,
output: moderatorOutput,
agent: moderator,
needs,
deps,
depsOptional: true,
...moderatorTaskProps,
children: moderatorChildren,
});
return React.createElement(Sequence, null, parallelEl, moderatorTask);
}
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);
}
import React from "react";
/** @typedef {import("./ParallelProps.ts").ParallelProps} ParallelProps */
/**
* @param {ParallelProps} props
*/
export function Parallel(props) {
if (props.skipIf)
return null;
// Align prop sanitization with other structural components. `label` names
// the phase group in run views (graph, the Claude /workflows mirror).
const next = {
maxConcurrency: props.maxConcurrency,
subtreeConcurrency: props.subtreeConcurrency,
id: props.id,
...(props.label === undefined ? {} : { label: props.label }),
};
return React.createElement("smithers:parallel", next, props.children);
}
// @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 < b && c`, and injected JSON schema hints (`"key"`) arrive as
* `"key"`, both of which corrupt the agent-facing text.
* `&` is decoded last so an escaped literal (`&lt;`, meaning the source
* text `<`) is not double-decoded into `<`.
* @param {string} html
* @returns {string}
*/
function decodeHtmlEntities(html) {
return html
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/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,
},
};
}
import type React from "react";
import type { AgentLike } from "@smithers-orchestrator/agents/AgentLike";
import type { PanelistConfig } from "./PanelistConfig.ts";
import type { OutputTarget } from "./OutputTarget.ts";
/** Extra Task props applied to a panel's generated tasks (panelists or moderator). */
type PanelTaskOptions = {
continueOnFail?: boolean;
timeoutMs?: number;
heartbeatTimeoutMs?: number;
retries?: number;
};
export type PanelProps = {
id?: string;
/**
* Panelists. Each entry is a single agent, a {@link PanelistConfig}, or a
* failover CHAIN (`AgentLike[]`). A chain becomes one panelist whose task
* runs it as a failover sequence.
*/
panelists: Array<PanelistConfig | AgentLike | AgentLike[]>;
moderator: AgentLike | AgentLike[];
panelistOutput: OutputTarget;
moderatorOutput: OutputTarget;
strategy?: "synthesize" | "vote" | "consensus";
minAgree?: number;
maxConcurrency?: number;
/** Extra Task props applied to every panelist task (e.g. continueOnFail, timeouts). */
panelistTaskProps?: PanelTaskOptions;
/** Extra Task props applied to the moderator task. */
moderatorTaskProps?: PanelTaskOptions;
skipIf?: boolean;
children: string | React.ReactNode;
};
import type { AgentLike } from "@smithers-orchestrator/agents/AgentLike";
export type PanelistConfig = {
/** A single agent, or a failover CHAIN (`AgentLike[]`) run as one panelist. */
agent: AgentLike | AgentLike[];
role?: string;
label?: string;
};