local F = require("flow_dsl")
local M = {}
M._authoring_warnings = {}
function M.take_authoring_warnings()
local out = M._authoring_warnings
M._authoring_warnings = {}
return out
end
function M.bp(t)
return t
end
function M.agent(t)
local out = {}
for k, v in pairs(t) do
if k ~= "md" then
out[k] = v
end
end
out["$agent_md"] = t.md
return out
end
local Placeholder = {}
Placeholder.__index = Placeholder
local function is_placeholder(v)
return type(v) == "table" and getmetatable(v) == Placeholder
end
function M.from(stage_id)
return setmetatable({ stage_id = stage_id }, Placeholder)
end
function M.stage(id)
return function(t)
t.id = id
return t
end
end
local function default_input_path(stage_id)
return "$.d." .. stage_id
end
local function default_out_path(stage_id)
return "$." .. stage_id
end
local function default_counter_path(stage_id)
return "$." .. stage_id .. "_n"
end
local function gate_cond(out_path, halt_on_values)
local verdict_path = out_path .. '.parts["verdict"]'
if #halt_on_values == 1 then
return F.p(verdict_path):eq(halt_on_values[1])
end
local eqs = {}
for i, v in ipairs(halt_on_values) do
eqs[i] = F.p(verdict_path):eq(v)
end
return F.any(eqs)
end
local function skip_cond(verdict_path, skip_on_values)
return F.lit(skip_on_values):contains(F.p(verdict_path))
end
local function resolve_input_path(input, stage_id, outs, chain_default)
if input == nil then
return chain_default or default_input_path(stage_id)
end
if is_placeholder(input) then
local target = outs[input.stage_id]
if target == nil then
error(
'bp_dsl: B.from("' .. tostring(input.stage_id) .. '") references an undefined stage',
0
)
end
return target
end
return input
end
local function build_step(rec, outs, chain_default)
local input_path = resolve_input_path(rec.input, rec.id, outs, chain_default)
return F.step({
id = rec.id,
agent = rec.agent,
input = F.p(input_path),
out = F.p(rec._out),
})
end
local FANOUT_JOIN_MODE_LIST = { "all", "any", "race", "all_settled" }
local FANOUT_JOIN_MODES = {}
for _, mode in ipairs(FANOUT_JOIN_MODE_LIST) do
FANOUT_JOIN_MODES[mode] = true
end
local DEFAULT_BIND_PATH = "$.item"
local DEFAULT_JOIN = "all"
local DEFAULT_LANE_OUT_PATH = "$.branch_out"
local function default_lane_out_path(lane_name)
return "$.lane." .. lane_name
end
local function normalize_lanes(stage_id, lanes)
local where = 'bp_dsl: stage "' .. tostring(stage_id) .. '": '
if type(lanes) ~= "table" then
error(where .. "fanout.lanes must be an ordered array, got " .. type(lanes), 0)
end
local n = #lanes
local count = 0
for _ in pairs(lanes) do
count = count + 1
end
if count == 0 then
error(
where
.. "fanout.lanes is empty — declare at least one lane, or use"
.. " fanout.agent for the homogeneous (one agent, N items) shape.",
0
)
end
if n ~= count then
error(
where
.. 'fanout.lanes must be an ordered array ({ "danger", { lane ='
.. ' "leak", agent = "gate-leak" } }), not a keyed table — `pairs`'
.. " order is undefined, so a keyed table would emit a"
.. " non-deterministic lane order.",
0
)
end
local out = {}
for i = 1, n do
local raw = lanes[i]
if type(raw) == "string" then
out[i] = { lane = raw, agent = raw }
elseif type(raw) == "table" then
local name = raw.lane or raw.agent
if name == nil then
error(
where
.. "fanout.lanes["
.. i
.. "] needs a `lane` name or an `agent` (a bare string is"
.. " shorthand for both).",
0
)
end
out[i] = {
lane = name,
agent = raw.agent or name,
input = raw.input,
out = raw.out,
}
else
error(
where
.. "fanout.lanes["
.. i
.. "] must be a lane-name string or a { lane =, agent =, input =,"
.. " out = } table, got "
.. type(raw),
0
)
end
end
return out
end
local function validate_fanout(rec)
local fo = rec.fanout
if fo == nil then
return
end
local where = 'bp_dsl: stage "' .. tostring(rec.id) .. '": '
if type(fo) ~= "table" then
error(where .. "fanout must be a table, got " .. type(fo), 0)
end
if rec.agent ~= nil then
error(
where
.. "`agent` and `fanout` are mutually exclusive — a fanout stage"
.. " names its agent inside the fanout record (fanout.agent for one"
.. " agent over N items, fanout.lanes for one agent per lane).",
0
)
end
if rec.retry ~= nil then
error(
where
.. "`retry` is not supported on a fanout stage — the retry loop's"
.. " cond reads `<out>.parts[\"verdict\"]`, but a fanout's `out`"
.. " holds the join result rather than one agent's verdict, and a"
.. " `retry.fix` step has no lane ctx to write back into. Put the"
.. " retry on the aggregate stage that reduces this stage's `out`"
.. " to a scalar verdict.",
0
)
end
if fo.agent ~= nil and fo.lanes ~= nil then
error(
where
.. "fanout.agent and fanout.lanes are mutually exclusive — agent is"
.. " the homogeneous shape (one agent over N items), lanes is the"
.. " heterogeneous shape (one agent per lane).",
0
)
end
if fo.agent == nil and fo.lanes == nil then
error(
where
.. "fanout needs either agent = \"<name>\" (one agent over N items)"
.. " or lanes = { ... } (one agent per lane).",
0
)
end
local join = fo.join or DEFAULT_JOIN
if not FANOUT_JOIN_MODES[join] then
error(
where
.. "fanout.join must be one of "
.. table.concat(FANOUT_JOIN_MODE_LIST, " / ")
.. ", got "
.. tostring(join),
0
)
end
if fo.lanes ~= nil then
rec._fanout_lanes = normalize_lanes(rec.id, fo.lanes)
end
end
local function resolve_items(items, outs)
if is_placeholder(items) then
local target = outs[items.stage_id]
if target == nil then
error(
'bp_dsl: B.from("' .. tostring(items.stage_id) .. '") references an undefined stage',
0
)
end
return F.p(target)
end
return items
end
local function build_lane_body(lanes, idx, bind_path, outs)
local lane = lanes[idx]
local step = F.step({
agent = lane.agent,
input = F.p(resolve_input_path(lane.input, lane.lane, outs, nil)),
out = F.p(lane.out or default_lane_out_path(lane.lane)),
})
if idx >= #lanes then
return step
end
return F.branch({
cond = F.p(bind_path):eq(lane.lane),
on_true = step,
on_false = build_lane_body(lanes, idx + 1, bind_path, outs),
})
end
local function build_fanout(rec, outs, chain_default)
local fo = rec.fanout
local bind_path = fo.bind or DEFAULT_BIND_PATH
local items = resolve_items(fo.items, outs)
local body
if rec._fanout_lanes ~= nil then
local lanes = rec._fanout_lanes
body = build_lane_body(lanes, 1, bind_path, outs)
if items == nil then
local names = {}
for i, lane in ipairs(lanes) do
names[i] = lane.lane
end
items = F.lit(names)
end
else
body = F.step({
agent = fo.agent,
input = F.p(bind_path),
out = F.p(fo.lane_out or DEFAULT_LANE_OUT_PATH),
})
if items == nil then
items = F.p(resolve_input_path(rec.input, rec.id, outs, chain_default))
end
end
return F.fanout({
items = items,
bind = F.p(bind_path),
join = fo.join or DEFAULT_JOIN,
out = F.p(rec._out),
body = body,
})
end
function M.pipeline(spec)
local halt_on = spec.halt_on or {}
local halted_at = spec.halted_at or "$.halted_at"
local done = spec.done
local chain = spec.chain == true
local gate_default = spec.gate_default or "explicit"
if gate_default ~= "explicit" and gate_default ~= "auto" then
error(
'bp_dsl: gate_default must be "explicit" (default) or "auto", got '
.. tostring(gate_default),
0
)
end
local function stage_wants_gate(rec)
if rec.gate == false then
return false
elseif rec.gate == true then
return true
elseif rec.retry ~= nil then
return true
elseif rec.halt_on ~= nil then
return true
elseif gate_default == "auto" and #halt_on > 0 and rec.fanout == nil then
return true
else
return false
end
end
local stages = {}
for i, rec in ipairs(spec) do
stages[i] = rec
end
for _, rec in ipairs(stages) do
validate_fanout(rec)
end
local outs = {}
local function register_out(rec)
rec._out = rec.out or default_out_path(rec.id)
outs[rec.id] = rec._out
end
for _, rec in ipairs(stages) do
register_out(rec)
if rec.retry ~= nil then
register_out(rec.retry.fix)
end
end
local any_gate = false
local stage_ids = {}
for i, rec in ipairs(stages) do
stage_ids[i] = tostring(rec.id)
local wants_gate = stage_wants_gate(rec)
if wants_gate then
any_gate = true
end
if wants_gate and rec.fanout ~= nil then
M._authoring_warnings[#M._authoring_warnings + 1] = 'B.pipeline stage "'
.. tostring(rec.id)
.. '": a verdict gate on a fanout stage compares '
.. tostring(rec._out)
.. '.parts["verdict"], but '
.. tostring(rec._out)
.. " holds the fanout's join result — not one agent's verdict — so"
.. " the gate can never fire. Put the gate on the aggregate stage"
.. ' that reads this one (B.stage "aggregate" { agent = ..., input ='
.. ' B.from "'
.. tostring(rec.id)
.. '", gate = true }) and reduces the join result to a scalar'
.. " verdict. The gate is emitted as written."
end
end
if #halt_on > 0 then
if not any_gate then
M._authoring_warnings[#M._authoring_warnings + 1] = "B.pipeline stages ["
.. table.concat(stage_ids, ", ")
.. "]: halt_on = {"
.. table.concat(halt_on, ", ")
.. "} is declared at the pipeline level but no stage emits a verdict"
.. " gate — gates are opt-in since bafe47d4, so this pipeline can"
.. " never halt. Add gate = true (or a stage-level halt_on / retry)"
.. " to at least one stage, or set gate_default = \"auto\" to restore"
.. " the pre-flip cascade for this source."
end
end
local function build_from(idx, rest_else)
if idx > #stages then
return rest_else
end
local rec = stages[idx]
local this_halt_on = rec.halt_on or halt_on
local chain_default
if chain and idx >= 2 then
chain_default = stages[idx - 1]._out
end
local step_node
if rec.fanout ~= nil then
step_node = build_fanout(rec, outs, chain_default)
else
step_node = build_step(rec, outs, chain_default)
end
local rest = build_from(idx + 1, rest_else)
local skip_on = rec.skip_on
local wants_skip_guard = skip_on ~= nil and #skip_on > 0
local skip_verdict_path
if wants_skip_guard then
local input_path =
resolve_input_path(rec.input, rec.id, outs, chain_default)
skip_verdict_path = input_path .. '.parts["verdict"]'
end
local body_children = { step_node }
if rec.retry ~= nil then
local fix_step = build_step(rec.retry.fix, outs)
local max = rec.retry.max
local counter_path = rec.retry.counter or default_counter_path(rec.id)
local loop_cond = F.p(counter_path):lt(max):And(gate_cond(rec._out, this_halt_on))
body_children[#body_children + 1] = F.loop_({
counter = F.p(counter_path),
cond = loop_cond,
max = max + 1,
body = F.seq({ fix_step, step_node }),
})
end
local children
if wants_skip_guard then
children = {
F.branch({
cond = skip_cond(skip_verdict_path, skip_on),
on_true = F.seq({}),
on_false = F.seq(body_children),
}),
}
else
children = body_children
end
local wants_gate = stage_wants_gate(rec)
if not wants_gate then
children[#children + 1] = rest
return F.seq(children)
end
children[#children + 1] = F.branch({
cond = gate_cond(rec._out, this_halt_on),
on_true = F.assign({ at = F.p(halted_at), value = F.lit(rec.id) }),
on_false = rest,
})
return F.seq(children)
end
local final_else
if done ~= nil then
final_else = F.assign({ at = F.p(done), value = F.lit(true) })
else
final_else = F.seq({})
end
return build_from(1, final_else)
end
return M