-
Notifications
You must be signed in to change notification settings - Fork 3
deterministic_routing
Status: Runtime deterministic-only; active workflow-plan migrations applied; caller/schedule migrations still pending Owners: workflow / orchestrator Last updated: 2026-06-06
Workflow routing steps are deterministic: the router reads a JSON file that names the route, and a few lines of Go pick the matching route and jump. No LLM evaluation inside routing. Any judgment needed to choose a route is done upstream (by an agent step or by the caller) and recorded as data in the route file.
This replaces the previous behavior, where the routing step called an LLM ("conditional
agent") to evaluate a routing_question and select a route — even in cases where the
decision is already fixed by a variable.
An audit of all workflow plans found 12 routing steps across 5 workflows
(upwork, linkedin, social-media, citymall-exploit-hacker, HRMS). Of those:
| Category | Count | Notes |
|---|---|---|
| Deterministic dispatch (switch on a variable/file) | 5 | LLM call is pure overhead |
| Terminal/chaining routers | 5 | phase-end switches; 2 are pure all-routes-to-end no-ops |
| Genuine LLM judgment | 2 | and even these read a file/JSON signal |
So 10 of 12 routers are not doing real LLM branching — they're switches,
phase-chain dispatchers, or no-ops. Several already used a legacy pattern where
the routing step description told an agent to write route_selection.json,
after which the routing LLM was asked to copy that value into its answer. That
put the LLM in the loop for no real branching decision and forced a
default_route_id fallback because the copy could fumble.
The two "genuine judgment" cases collapse too: the upstream step that produced the
signal (e.g. connection_test.json, or a findings analysis) is the natural place to
emit the route decision as data.
Conclusion: routing should be a deterministic switch; judgment belongs in the step that produces the signal.
A routing step reads one JSON route file and switches on it:
route file (canonical): { "select_route": "<route_id>" }
routing step logic (pure Go, no LLM):
1. read route_selection.json from the first available source:
a. routing step's output dir (runner/caller preseed)
b. declared prior-step route source
2. find the route in routes[] whose route_id == select_route
3. jump to that route's next_step_id
4. if no source file exists → use default_route_id
5. if a source file exists but value matches no route → hard error
6. if there is no usable default → hard error (surfaced to caller)
After resolving the route, every routing and branch step persists its selected
route in its own run-scoped execution folder. This is execution evidence for
that iteration even when the input came from a deliberately shared
route_source_file. Dynamic decisions produced by an earlier step must remain
run-scoped and be connected with context_dependencies; do not mirror them
through db/assets.
The step keeps its existing shape (routes[], default_route_id,
next_step_id per route). What is removed is the LLM evaluation
(routing_question is no longer evaluated by a model). A routing step's
description must be empty; any agent/probe/judgment work belongs in a prior
regular step that writes route_selection.json.
The route file is a data sink. Callers can preseed the router's own output folder, while prior steps write to their own output folders and expose that file as a declared route source.
| Producer | When | Who decides |
|---|---|---|
run_workflow / run_step param |
Caller already knows the flow | the caller; orchestrator writes the router's own route file |
| A prior agent step | The route needs judgment | the prior step writes a route file in its own output folder |
| Builder / plan default | A fixed default baked into the plan | the plan author |
(none) → default_route_id
|
No source file exists | fallback |
1. run_workflow / run_step param (caller's explicit choice — wins)
2. routing step's own route_selection.json (caller-preseeded file)
3. declared prior-step route source (agent judgment)
4. default_route_id (only when no route file exists)
→ if an explicit value is present but invalid → error (no silent default)
run_workflow, run_step, and the workshop builder's run_full_workflow
tool have an optional route_selections parameter. The runner carries that
map into execution options; the workflow controller pre-seeds route files after
run-folder/group resolution and cleanup, before the first step executes.
- Shape:
route_selections : { <routing_step_id> : <route_id | next_step_id> }— a map, so workflows with multiple routers (and chaining) are supported.
The value may name either the abstract route_id or the route's
next_step_id (the destination step). The runner resolves and validates:
value matches a route_id in that step's routes[] → use it
else value matches exactly one next_step_id in that step's routes[] → use that route
else value matches multiple next_step_id entries → error (ambiguous)
else → error (not a valid route for this router)
-
Primary form is
route_id(stable semantic label; survives step renames). -
next_step_idis accepted as an alias only when it maps to exactly one route. - Either way the value is validated to be one of that router's declared routes —
so this is a real branch, never an arbitrary
goto. - On disk we always normalize to the canonical
route_id:{ "select_route": "<route_id>" }.
| Layer | Change |
|---|---|
controller_routing.go (executeRoutingStep) |
Replaced the conditionalAgent.EvaluateRouting(...) call path with deterministic file/default resolution. The branch selection is now "read file → validate → switch"; routing descriptions are rejected. |
controller_routing_deterministic.go |
Added the deterministic resolver, route value normalization, prior-step source lookup, and route_selections pre-seeding. |
workflow_run_tools.go (run_workflow, run_step) |
Added optional route_selections to the tool schemas and parser. No scalar route sugar was added. |
planning_exports.go (run_full_workflow) |
Added optional route_selections so the workflow builder can select fixed branches from the user's chat request without adding a redundant human_input step. |
runWorkflowInternal / server request path |
Parses route_selections and carries it through execution_options; file writing happens later because final group run folders are resolved during execution. |
| execution controller / batch setup | Validates route_selections against each router's routes[], then writes route_selection.json into each routing step's output dir after cleanup and before step execution. |
| routing source resolution | Checks the router's own preseeded route file first, then route_source_file, then context_dependencies entries named route_selection.json, preserving folder ownership. |
routing.md guidance |
Rewritten: routing is a deterministic switch; judgment goes in an upstream step or the caller; document the file contract and route_selections. |
| active workflow plans | Migrated active upwork, social-media, linkedin, HRMS, and citymall-exploit-hacker plans away from routing-step LLM classification. Variable-mode routers now expect caller route_selections; judgment routers consume producer-owned route_selection.json. |
The old conditional-agent helper has been removed. Runtime workflow routing resolves only deterministic route files, explicit caller selections, or declared defaults.
The loop-guard in controller_execution.go (a route may be selected at most twice
before an "infinite loop" error) is unaffected and still bounds backward-jump loops.
Active upwork plan migration is applied.
- Drop the routing LLM (deterministic read replaces it).
-
route-by-mode: theFLOW_MODEvariable and legacy routing description that copied it into the file are replaced by therun_workflowroute_selectionsparam (the runner writes the file). RemoveFLOW_MODEfromvariables/variables.jsongroups. - Repoint upwork's schedules from "group with
FLOW_MODE=search" toroute_selections = { "route-by-mode": "search" }. - Verify:
route_selections={"route-by-mode":"search"}runs the search block; omitting the param falls back todefault_route_id(profile).
-
social-media: browser preflight now writesroute_selection.json; run-mode routing expects callerroute_selections. -
linkedin,HRMS: variable-driven dispatchers now expect callerroute_selections. -
citymall/review-and-expand: the upstream analysis step writes{ "select_route": "..." }in its own output folder, and the routing step reads that file through an explicitroute_selection.jsondependency. - Pure all-routes-to-
endterminators: out of scope here, but they should be collapsed to a plain end/regular step rather than a routing step.
Workflow plan files under workspace-docs/Workflow/.../planning/plan.json may be
local workspace state, not git-tracked source. Another laptop must have both
the new runtime code and migrated local plan JSON.
-
Update the runtime code first. Pull/deploy the code that includes deterministic routing and
route_selections. Old runtime code will ignore the new contract and still try to LLM-evaluate routing. -
Find active routing steps.
find workspace-docs/Workflow -path '*/planning/plan.json' -print \ | xargs jq -r ' input_filename as $file | .steps[]? | select(.type == "routing") | [$file, .id, .title, (.description // ""), (.default_route_id // "")] | @tsv'
-
Migrate variable routers to caller
route_selections. Remove routing-step descriptions that read variables likeFLOW_MODE,RUN_MODE, orWORKFLOW_MODE. Keep the router deterministic (description: "") and pass the choice when starting the workflow:{ "route_selections": { "route-by-mode": "search", "step-run-mode-router": "propose_new", "step-workflow-router": "route-post", "workflow-mode-router": "route-monthly" } }Use only the routing steps present in that workflow run. Values may be either a route's
route_idor a uniquenext_step_id;route_idis preferred. -
Migrate judgment routers to producer-owned files. The prior step that makes the decision must be a normal step before the routing step. It writes
route_selection.jsonin its own output folder and declares it incontext_output:{ "context_output": "analysis.json, route_selection.json" }The routing step then consumes only that route file:
{ "type": "routing", "context_dependencies": ["route_selection.json"] }The file body must be:
{ "select_route": "<route_id>" } -
Update schedules/callers. Any cron, script, or saved run config that used variables for route choice must pass
route_selectionsinstead:Old variable New route selection FLOW_MODE=search{ "route-by-mode": "search" }RUN_MODE=propose_new{ "step-run-mode-router": "propose_new" }VAR_RUN_MODE=engage{ "step-workflow-router": "route-engage" }WORKFLOW_MODE=monthly{ "workflow-mode-router": "route-monthly" } -
Validate the migrated workspace.
find workspace-docs/Workflow -path '*/planning/plan.json' -print \ | xargs jq empty
Then run a structural route check:
node <<'NODE' const fs = require("fs"); const { execSync } = require("child_process"); const files = execSync("find workspace-docs/Workflow -path '*/planning/plan.json' -print", { encoding: "utf8" }) .trim() .split("\n") .filter(Boolean); let errors = []; const matchesOutput = (output, dep) => String(output || "").split(",").map(s => s.trim()).includes(dep); for (const file of files) { const steps = JSON.parse(fs.readFileSync(file, "utf8")).steps || []; const ids = new Set(steps.map(s => s.id).filter(Boolean)); for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (step.type !== "routing") continue; const routes = step.routes || []; const routeIDs = new Set(routes.map(r => r.route_id)); if (String(step.description || "").trim()) errors.push(`${file}: ${step.id} must clear routing description`); if (routes.length < 2) errors.push(`${file}: ${step.id} has fewer than 2 routes`); if (step.default_route_id && !routeIDs.has(step.default_route_id)) errors.push(`${file}: ${step.id} invalid default_route_id`); for (const route of routes) { if (route.next_step_id !== "end" && !ids.has(route.next_step_id)) { errors.push(`${file}: ${step.id}/${route.route_id} points to missing ${route.next_step_id}`); } } if ((step.context_dependencies || []).includes("route_selection.json")) { const hasProducer = steps.slice(0, i).some(s => matchesOutput(s.context_output, "route_selection.json")); if (!hasProducer) errors.push(`${file}: ${step.id} depends on route_selection.json but no prior producer declares it`); } } } if (errors.length) { console.error(errors.join("\n")); process.exit(1); } console.log("routing plan validation passed"); NODE
-
Smoke-test one route per migrated workflow. For example:
run_workflow("Workflow/upwork", "default", route_selections={ "route-by-mode": "search" }) run_workflow("Workflow/social-media", "default", route_selections={ "step-run-mode-router": "propose_new" }) run_workflow("Workflow/linkedin", "default", route_selections={ "step-workflow-router": "route-engage" }) run_workflow("Workflow/HRMS", "default", route_selections={ "workflow-mode-router": "route-monthly" })
If a workflow has a preflight/judgment router, confirm the producer step writes
route_selection.jsonbefore the router runs.
-
File name / field. Canonical file is
route_selection.jsonwithselect_route. Compatibility aliasesroute_idandselected_route_idare accepted. Values are normalized to route IDs. -
JSON file always. Routing never reads variables directly. Caller overrides are
written as
route_selection.jsonby the runner. -
Keep
routing_question. It remains for compatibility and plan readability, but it is not evaluated by a model. -
No scalar
routesugar. Use the explicitroute_selectionsmap:{ "<routing_step_id>": "<route_id | next_step_id>" }.
- Migrate callers/schedules that currently pass routing variables (for example
FLOW_MODE,RUN_MODE, orWORKFLOW_MODE) toroute_selections. - Collapse pure all-routes-to-
endterminators where they add no real branch.
// caller
run_workflow("Workflow/upwork", "default", route_selections={ "route-by-mode": "search" })
// runner (before execution) writes:
// runs/<run>/execution/route-by-mode/route_selection.json
// { "select_route": "search" }
// routing step "route-by-mode" (deterministic):
// reads select_route = "search"
// routes[]: search → next_step_id "search-scrape-jobs"
// → jumps to search-scrape-jobs; runs the search block
// no param next time? → falls back to default_route_id ("profile")Auto-synced from docs/ on main. Edit there, not here.