-
Notifications
You must be signed in to change notification settings - Fork 10
Description
Smithers Nested <Loop> / <Ralph> Rejected Despite <Sequence> Separator
Summary
Smithers rejects any <Loop> (aka <Ralph>) nested inside another <Loop>, even when separated by <Sequence> or other structural nodes. The guard checks a single ralphId flag that propagates through the entire subtree with no reset.
The author suggested this pattern should work, but it does not on the current version.
Why this matters
A common orchestration pattern is an outer retry loop (e.g. "keep going until all units are done") containing an inner review loop per unit (e.g. "review → fix → re-review until LGTM"):
<Loop id="outer-ralph-loop"> ← outer: iterate until all units landed
<Sequence>
<QualityPipeline> ← per-unit pipeline
<ReviewLoop>
<Loop id="review-loop"> ← inner: review/fix cycle
...
</Loop>
</ReviewLoop>
</QualityPipeline>
</Sequence>
</Loop>
Current behavior
Relevant code:
- Extract-phase guard:
src/dom/extract.ts:146-148 - Scheduler-phase guard:
src/engine/scheduler.ts:72-73 Loopcomponent emitssmithers:ralphtag:src/components/Ralph.ts:14
Both extract.ts and scheduler.ts track a single ralphId / inRalph flag. Once set by an outer <Loop>, it propagates into all descendants. Any descendant <Loop> triggers the error unconditionally — <Sequence>, <Parallel>, custom components, etc. do not reset the flag.
extract.ts (line 146)
if (node.tag === "smithers:ralph") {
if (ralphId) {
throw new Error("Nested <Ralph> is not supported.");
}
// ...
ralphId = id; // set for all descendants
}scheduler.ts (line 72)
if (ctx.inRalph && tag === "smithers:ralph") {
throw new Error("Nested <Ralph> is not supported.");
}
// ...
const nextInRalph = ctx.inRalph || tag === "smithers:ralph";Expected behavior
The author's stated expectation:
Direct nesting is unsupported (because it indicates a bug) but you can do this:
<Ralph id="outer" until={false}> <Sequence> <Ralph id="inner" until={innerFinished}> <Task id="innerTask" output={outputs.outputA}> {{ value: 1 }} </Task> </Ralph> </Sequence> </Ralph>
This implies that nesting through a structural node like <Sequence> should be allowed. The current code does not distinguish between direct nesting (<Ralph><Ralph>) and indirect nesting (<Ralph><Sequence><Ralph>).
Impact
- Prevents multi-level loop patterns (outer orchestration + inner review/retry)
- Forces workarounds like flattening loops into a single outer loop with manual iteration tracking
- Ralphinho's
ScheduledWorkflow+ReviewLoopcomposition is broken by this
MRE
Save as mre-nested-ralph.tsx
import React from "react";
import { z } from "zod";
import {
createSmithers,
Sequence,
Loop,
Task,
runWorkflow,
type AgentLike,
} from "smithers-orchestrator";
const { smithers, outputs, Workflow } = createSmithers(
{ outputA: z.object({ value: z.number() }) },
{ dbPath: "/tmp/mre-nested-ralph.db" },
);
const stubAgent: AgentLike = {
id: "stub",
generate: async () => ({ value: 1 }),
};
const workflow = smithers((ctx) => {
const innerResult = ctx.latest("outputA", "innerTask");
const innerFinished = innerResult != null;
return (
<Workflow name="nested-ralph-mre">
<Loop id="outer" until={false} maxIterations={2} onMaxReached="return-last">
<Sequence>
<Loop id="inner" until={innerFinished} maxIterations={2} onMaxReached="return-last">
<Task id="innerTask" output={outputs.outputA} agent={stubAgent}>
Return a JSON object with a "value" field set to 1.
</Task>
</Loop>
</Sequence>
</Loop>
</Workflow>
);
});
const result = await runWorkflow(workflow, { input: {} });
console.log("status:", result.status);Run
rm -f /tmp/mre-nested-ralph.db
bun run mre-nested-ralph.tsxObserved output
level=ERROR message="workflow run failed with unhandled error" error="Nested <Ralph> is not supported."
The workflow fails immediately during the extract phase, before any task executes.
What the MRE demonstrates
- Two
<Loop>nodes with distinctids - Separated by a
<Sequence>(not directly nested) - Smithers still rejects this as "nested
<Ralph>" - The
ralphIdflag propagates through<Sequence>without reset