// Props
import { Panel } from "smthrs";
// 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. - Panelists may share a
label/role(twosecurityreviewers): colliding ids get an index suffix ({prefix}-security-1), so every panelist keeps its own task. 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";
import { useOptionalSmithersContext } from "./useOptionalSmithersContext.js";
/** @typedef {import("@smthrs/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) {
const ctx = useOptionalSmithersContext();
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.
//
// Two panelists sharing a label/role (two "security" reviewers) is a natural
// config, so suffix later collisions instead of emitting duplicate Task ids —
// otherwise graph extraction throws DUPLICATE_ID and the needs/deps maps
// collapse a panelist via object-key overwrite. `${prefix}-moderator` is
// reserved by the moderator task below.
const seenIds = new Set([`${prefix}-moderator`]);
const taskIds = normalized.map((p, i) => {
const base = `${prefix}-${p.label ?? p.role ?? `panelist-${i}`}`;
let taskId = base;
let suffix = i;
while (seenIds.has(taskId)) taskId = `${base}-${suffix++}`;
seenIds.add(taskId);
return taskId;
});
// 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;
});
// Resolve each panelist from its latest persisted iteration. Generic Task
// deps use ctx.outputMaybe(), whose implicit iteration is 0 when concurrent
// loops coexist, so using deps here can feed a resumed moderator stale
// iteration-0 reviews. latest() still honors the current loop scope while
// selecting the newest row within it.
const panelistOutputs = {};
taskIds.forEach((taskId) => {
const output = ctx?.latest(panelistOutput, taskId);
if (output !== undefined) panelistOutputs[taskId] = output;
});
// 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 outputsText = taskIds
.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,
...moderatorTaskProps,
dependsOn: [...new Set([...taskIds, ...(moderatorTaskProps?.dependsOn ?? [])])],
children: moderatorChildren(panelistOutputs),
});
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 }),
...(props.failurePolicy === undefined ? {} : { failurePolicy: props.failurePolicy }),
};
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 }),
...(props.priority === undefined ? {} : { priority: props.priority }),
...(props.failurePolicy === undefined ? {} : { failurePolicy: props.failurePolicy }),
};
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 { 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 });
import React from "react";
import { SmithersContext } from "@smthrs/react-reconciler/context";
/**
* Read the workflow context when React is rendering the component, but allow
* direct structural expansion tests to call composite wrappers as plain
* functions. In that direct-call path React throws the standard invalid hook
* error; treating it as "no context yet" preserves the static element shape.
*/
export function useOptionalSmithersContext() {
try {
return React.useContext(SmithersContext);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/Invalid hook call|dispatcher\.useContext|useContext/i.test(message)) {
return null;
}
throw error;
}
}
import type React from "react";
import type { AgentLike } from "@smthrs/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 "@smthrs/agents/AgentLike";
export type PanelistConfig = {
/** A single agent, or a failover CHAIN (`AgentLike[]`) run as one panelist. */
agent: AgentLike | AgentLike[];
role?: string;
label?: string;
};