Run steps in parallel
This guide runs several tools on the same input at the same time, with a fanout step, and chooses how their results come back.
A fanout step runs several tools at once, on the same input, and collects
their outputs into one.
Before you start
Section titled “Before you start”- A pack with the tools from Write a flow, such as
acme.get-order, on a runtime such askindgi dev, with Kindgi 0.1's CLI.
Fan out to several tools
Section titled “Fan out to several tools”Ask your coding agent: "I want a flow that scores an order's risk and quotes
its shipping at the same time." Kindgi's skills in your pack tell it how
(How it knows Kindgi). For reference, the
tools and the flow look like this. This guide calls the flow
acme.assess-order, and it uses two new read-only tools:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const defined = defineTool({ id: 'acme.score-risk' as ToolId, description: 'Scores the fraud risk of an order.', version: '0.1.0', input: z.object({ total: z.number() }), output: z.object({ level: z.enum(['low', 'high']) }), effects: [], mutating: false, handler: async ({ total }) => ({ level: total > 1000 ? ('high' as const) : ('low' as const) }),});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
const defined = defineTool({ id: 'acme.quote-shipping' as ToolId, description: 'Quotes how many days an order takes to ship.', version: '0.1.0', input: z.object({ items: z.array(z.object({ sku: z.string(), quantity: z.number() })) }), output: z.object({ days: z.number().int() }), effects: [], mutating: false, handler: async ({ items }) => ({ days: 2 + items.length }),});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({ id: 'acme.assess-order', version: '0.1.0', name: 'Assess an order', description: 'Scores the risk and quotes shipping for an order, at the same time.', nodes: [ { id: 'order', kind: 'tool', ref: 'acme.get-order', inputMapping: { orderId: { path: 'runInput.orderId' } }, }, { id: 'assess', kind: 'fanout', convergence: 'all-succeed', branches: [ { branchId: 'risk', handler: 'acme.score-risk', outputSchema: { type: 'object', properties: { level: { type: 'string' } }, required: ['level'] }, }, { branchId: 'shipping', handler: 'acme.quote-shipping', outputSchema: { type: 'object', properties: { days: { type: 'integer' } }, required: ['days'] }, }, ], }, ], edges: [ { id: 'e1', from: '$start', to: 'order' }, { id: 'e2', from: 'order', to: 'assess' }, { id: 'e3', from: 'assess', to: '$end' }, ], output: { mapping: { risk: { path: 'nodeOutputs.assess.risk.level' }, shippingDays: { path: 'nodeOutputs.assess.shipping.days' }, }, },});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from typing import Literal
from pydantic import BaseModel
from kindgi import tool
class RiskInput(BaseModel): total: float
class Risk(BaseModel): level: Literal["low", "high"]
@tool(id="acme.score-risk", mutating=False)def score_risk(input: RiskInput) -> Risk: """Scores the fraud risk of an order.""" return Risk(level="high" if input.total > 1000 else "low")from pydantic import BaseModel
from kindgi import tool
class Item(BaseModel): sku: str quantity: int
class ShippingInput(BaseModel): items: list[Item]
class Shipping(BaseModel): days: int
@tool(id="acme.quote-shipping", mutating=False)def quote_shipping(input: ShippingInput) -> Shipping: """Quotes how many days an order takes to ship.""" return Shipping(days=2 + len(input.items))from kindgi import Flow
assess_order = Flow( id="acme.assess-order", version="0.1.0", name="Assess an order", description="Scores the risk and quotes shipping for an order, at the same time.", nodes=[ { "id": "order", "kind": "tool", "ref": "acme.get-order", "inputMapping": {"orderId": {"path": "runInput.orderId"}}, }, { "id": "assess", "kind": "fanout", "convergence": "all-succeed", "branches": [ { "branchId": "risk", "handler": "acme.score-risk", "outputSchema": { "type": "object", "properties": {"level": {"type": "string"}}, "required": ["level"], }, }, { "branchId": "shipping", "handler": "acme.quote-shipping", "outputSchema": { "type": "object", "properties": {"days": {"type": "integer"}}, "required": ["days"], }, }, ], }, ], edges=[ {"id": "e1", "from": "$start", "to": "order"}, {"id": "e2", "from": "order", "to": "assess"}, {"id": "e3", "from": "assess", "to": "$end"}, ], output={ "mapping": { "risk": {"path": "nodeOutputs.assess.risk.level"}, "shippingDays": {"path": "nodeOutputs.assess.shipping.days"}, } },)package acme.tools;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import com.fasterxml.jackson.annotation.JsonProperty;import com.kindgi.pack.Tool;
public final class ScoreRisk { // A branch gets the whole order: its other fields are left out. @JsonIgnoreProperties(ignoreUnknown = true) public record Input(double total) {}
public enum Level { @JsonProperty("low") LOW, @JsonProperty("high") HIGH }
public record Output(Level level) {}
public static final Tool<Input, Output> TOOL = Tool.define("acme.score-risk") .description("Scores the fraud risk of an order.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .handler((input, ctx) -> new Output(input.total() > 1000 ? Level.HIGH : Level.LOW));
private ScoreRisk() {}}package acme.tools;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;import com.kindgi.pack.Tool;import java.util.List;
public final class QuoteShipping { public record Item(String sku, double quantity) {}
// A branch gets the whole order: its other fields are left out. @JsonIgnoreProperties(ignoreUnknown = true) public record Input(List<Item> items) {}
public record Output(int days) {}
public static final Tool<Input, Output> TOOL = Tool.define("acme.quote-shipping") .description("Quotes how many days an order takes to ship.") .version("0.1.0") .input(Input.class) .output(Output.class) .mutating(false) .handler((input, ctx) -> new Output(2 + input.items().size()));
private QuoteShipping() {}}package acme.flows;
import com.kindgi.pack.Flow;import java.util.List;import java.util.Map;
public final class AssessOrder { public static final Flow FLOW = Flow.define("acme.assess-order") .version("0.1.0") .set("name", "Assess an order") .set("description", "Scores the risk and quotes shipping for an order, at the same time.") .node(Map.of("id", "order", "kind", "tool", "ref", "acme.get-order", "inputMapping", Map.of("orderId", Map.of("path", "runInput.orderId")))) .node(Map.of( "id", "assess", "kind", "fanout", "convergence", "all-succeed", "branches", List.of( Map.of("branchId", "risk", "handler", "acme.score-risk", "outputSchema", Map.of("type", "object", "properties", Map.of("level", Map.of("type", "string")), "required", List.of("level"))), Map.of("branchId", "shipping", "handler", "acme.quote-shipping", "outputSchema", Map.of("type", "object", "properties", Map.of("days", Map.of("type", "integer")), "required", List.of("days")))))) .edge("e1", "$start", "order") .edge("e2", "order", "assess") .edge("e3", "assess", "$end") .set("output", Map.of("mapping", Map.of( "risk", Map.of("path", "nodeOutputs.assess.risk.level"), "shippingDays", Map.of("path", "nodeOutputs.assess.shipping.days")))) .build();
private AssessOrder() {}}A branch gets the whole output of the step before it (the order). A record
refuses properties it doesn't name, so each branch's input says
@JsonIgnoreProperties(ignoreUnknown = true); a TypeScript zod object drops
them by itself.
package acme.tools
import com.fasterxml.jackson.annotation.JsonIgnorePropertiesimport com.kindgi.pack.scaladsl._
object ScoreRisk { // A branch gets the whole order: its other fields are left out. @JsonIgnoreProperties(ignoreUnknown = true) final case class Input(total: Double)
enum Level { case low, high }
final case class Output(level: Level)
val tool: Tool[Input, Output] = Tool[Input, Output]("acme.score-risk") .description("Scores the fraud risk of an order.") .version("0.1.0") .readOnly .handler((in, _) => Output(if (in.total > 1000) Level.high else Level.low))}package acme.tools
import com.fasterxml.jackson.annotation.JsonIgnorePropertiesimport com.kindgi.pack.scaladsl._
object QuoteShipping { final case class Item(sku: String, quantity: Double)
// A branch gets the whole order: its other fields are left out. @JsonIgnoreProperties(ignoreUnknown = true) final case class Input(items: Seq[Item])
final case class Output(days: Int)
val tool: Tool[Input, Output] = Tool[Input, Output]("acme.quote-shipping") .description("Quotes how many days an order takes to ship.") .version("0.1.0") .readOnly .handler((in, _) => Output(2 + in.items.size))}package acme.flows
import com.kindgi.pack.scaladsl._
object AssessOrder { val flow: Flow = Flow("acme.assess-order") .version("0.1.0") .set("name", "Assess an order") .set("description", "Scores the risk and quotes shipping for an order, at the same time.") .node(Map("id" -> "order", "kind" -> "tool", "ref" -> "acme.get-order", "inputMapping" -> Map("orderId" -> Map("path" -> "runInput.orderId")))) .node(Map( "id" -> "assess", "kind" -> "fanout", "convergence" -> "all-succeed", "branches" -> List( Map("branchId" -> "risk", "handler" -> "acme.score-risk", "outputSchema" -> Map("type" -> "object", "properties" -> Map("level" -> Map("type" -> "string")), "required" -> List("level"))), Map("branchId" -> "shipping", "handler" -> "acme.quote-shipping", "outputSchema" -> Map("type" -> "object", "properties" -> Map("days" -> Map("type" -> "integer")), "required" -> List("days")))))) .edge("e1", "$start", "order") .edge("e2", "order", "assess") .edge("e3", "assess", "$end") .set("output", Map("mapping" -> Map( "risk" -> Map("path" -> "nodeOutputs.assess.risk.level"), "shippingDays" -> Map("path" -> "nodeOutputs.assess.shipping.days")))) .build()}A branch gets the whole order, and a case class refuses properties it
doesn't name, so each branch's input says
@JsonIgnoreProperties(ignoreUnknown = true).
- Each branch has a
branchId, the tool it runs (handler), and theoutputSchemaits output must match. A branch'soutputSchemathat doesn't compile is refused when the flow is loaded or deployed (/nodes/<i>/branches/<k>/outputSchema), not at each run. - Every branch gets the fanout step's input: the output of the step before it,
here the whole order. Each tool takes the fields its input declares, such
as
totalanditems, and drops the rest. convergencedecides when the step is done and what it returns, as Convergence shows.
Run it:
pnpm exec kindgi runs start --flow=acme.assess-order --input='{"orderId":"A-200"}'uv run kindgi runs start --flow=acme.assess-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.assess-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.assess-order --input='{"orderId":"A-200"}'--flow is the flow, and --input its input. It prints the run, with both
tools' results:
{ … "status": "completed", … "output": { "risk": "high", "shippingDays": 4 }, …}In the journal, which kindgi runs journal <run-id> prints, both branches
are dispatched before either completes:
{"sequence": 6, "kind": "fanout.dispatched", "nodeId": "assess", "payload": {…, "handler": "acme.score-risk", "branchId": "risk", "fanoutNodeId": "assess"}, …}{"sequence": 7, "kind": "fanout.dispatched", "nodeId": "assess", "payload": {…, "handler": "acme.quote-shipping", "branchId": "shipping", "fanoutNodeId": "assess"}, …}{"sequence": 8, "kind": "fanout.branch-completed", "nodeId": "assess", "payload": {"output": {"days": 4}, "branchId": "shipping", "fanoutNodeId": "assess"}, …}{"sequence": 9, "kind": "fanout.branch-completed", "nodeId": "assess", "payload": {"output": {"level": "high"}, "branchId": "risk", "fanoutNodeId": "assess"}, …}{"sequence": 10, "kind": "fanout.converged", "nodeId": "assess", "payload": {"output": {"risk": {"level": "high"}, "shipping": {"days": 4}}, "convergence": "all-succeed", "fanoutNodeId": "assess"}, …}Convergence
Section titled “Convergence”convergence |
Done when | The step's output |
|---|---|---|
all-succeed |
every branch succeeded. The first failure fails the step and cancels the others | { "<branchId>": <output>, … } |
any-succeed |
the first branch succeeds, and the others are cancelled. It fails only if every branch fails | { "winnerBranchId": "<branchId>", "output": <output> } |
settle-all |
every branch finished, succeeded or failed. The step itself succeeds | { "<branchId>": { "status": "succeeded", "output": … } | { "status": "failed", "error": "…" }, … } |
With settle-all, a failed branch is data for the next step, not a failed
run. Here a settle-all fanout's stock branch runs acme.check-stock with
an input that doesn't fit it, next to risk. The step's output:
{ "risk": { "output": { "level": "high" }, "status": "succeeded" }, "stock": { "error": "input-validation-failed: Input for tool \"acme.check-stock\" failed validation", "status": "failed" }}With all-succeed, the same branch fails the run:
[branch-failure] Fanout "assess" all-succeed mode: branch "stock" failed — input-validation-failed: Input for tool "acme.check-stock" failed validationFanout or loop
Section titled “Fanout or loop”A fanout runs different tools on the same input. To run the same
steps on each element of a list, several at a time, use a foreach loop with
concurrency, as Repeat steps in a loop shows.