Skip to content

Chapter 4: Flows

A Flow is a declarative, chainable sequence of steps executed against a Context by an Engine. Each step adds, transforms, or queries the context.

The @flow decorator converts any async def f(context, engine) function into a Flow. The Flow class itself provides a fluent builder API.

Sequential steps

reply() appends an LLM response. append() and prepend() inject static content.

@pytest.mark.asyncio
async def test_sequential_flow():
    f = (
        Flow("greeter")
        .append("Greet the user warmly.")
        .reply()
    )
    c = Context([Message.user("Hello")])
    await f.execute(c, Engine(MockLLM(["Hi there!"])))
    assert c.messages[-1].role == "assistant"
    assert c.messages[-1].content == "Hi there!"

Conditional branching — when

when(prompt, then, otherwise) makes a boolean decision and routes to one of two sub-flows. Under the hood it calls engine.decide(). In tests, mock that:

@pytest.mark.asyncio
async def test_when_takes_true_branch():
    positive = Flow("positive").append("Response is positive.")
    negative = Flow("negative").append("Response is negative.")

    f = Flow("sentiment").when("Is the sentiment positive?", then=positive, otherwise=negative)
    c = Context([Message.user("I love it!")])

    engine = Engine(MockLLM())
    cot = MagicMock(); cot.result = True
    engine.create = AsyncMock(return_value=cot)

    await f.execute(c, engine)
    assert any("positive" in str(m.content) for m in c.messages)

Multi-way branching — branch

branch(prompt, **choices) routes to one of N sub-flows based on the LLM's string choice:

@pytest.mark.asyncio
async def test_branch_routes_to_correct_choice():
    math_flow  = Flow("math").append("Doing math.")
    code_flow  = Flow("code").append("Writing code.")

    f = Flow("router").branch("Which topic?", math=math_flow, code=code_flow)
    c = Context([Message.user("Help me with Python")])

    engine = Engine(MockLLM())
    cot = MagicMock(); cot.result = "code"
    engine.create = AsyncMock(return_value=cot)

    await f.execute(c, engine)
    assert any("Writing code" in str(m.content) for m in c.messages)

Looping — repeat

repeat(body, until, max_repeats) runs body until the until condition returns True or max_repeats is reached.

@pytest.mark.asyncio
async def test_repeat_runs_at_least_once():
    counter = {"n": 0}

    @flow
    async def increment(context: Context, engine: Engine):
        counter["n"] += 1

    engine = Engine(MockLLM())
    cot = MagicMock(); cot.result = True
    engine.create = AsyncMock(return_value=cot)

    f = Flow("loop").repeat(increment, until="Stop now?", max_repeats=3)
    c = Context([Message.user("go")])
    await f.execute(c, engine)
    assert counter["n"] >= 1

Parallel branches — fork

fork(*flows, aggregator) runs multiple sub-flows in parallel on clones of the current context. Their results are synthesized by an aggregator flow (or prompt).

@pytest.mark.asyncio
async def test_fork_runs_branches_in_parallel():
    branch_a = Flow("a").reply("Branch A")
    branch_b = Flow("b").reply("Branch B")

    f = Flow("parallel").fork(branch_a, branch_b, aggregator="Combine the results.")
    c = Context([Message.user("Analyze from two angles.")])
    await f.execute(c, Engine(MockLLM(["A result", "B result", "Summary of both."])))
    assert c.messages[-1].role == "assistant"

Compression — compress

compress() prunes context history to keep it within token budgets. With aggregator=None, it acts as a sliding window; with an aggregator it summarizes.

@pytest.mark.asyncio
async def test_compress_shrinks_context():
    long_history = [Message.user(f"msg {i}") for i in range(10)]
    c = Context(long_history)
    f = Flow("compress").compress(n=None, prefix_k=1, aggregator="Summarize.")
    await f.execute(c, Engine(MockLLM(["Summary here."])))
    # After compression: 1 prefix + 1 summary
    assert len(c.messages) <= 3

Custom steps — @flow decorator

The @flow decorator is the escape hatch for any step that doesn't fit the fluent API. The function receives (context, engine) directly.

@flow
async def my_custom_step(context: Context, engine: Engine):
    """Logs the last user message and adds a note."""
    last = context.messages[-1]
    context.append(f"[log] Last message was: {last.content}")

@pytest.mark.asyncio
async def test_custom_flow_step():
    c = Context([Message.user("test input")])
    await my_custom_step.execute(c, Engine(MockLLM(["ok"])))
    assert any("[log]" in str(m.content) for m in c.messages)

Test file

import pytest
from unittest.mock import AsyncMock, MagicMock
from lingo import Message, Flow, flow
from lingo.context import Context
from lingo.engine import Engine
from lingo.mock import MockLLM

<<ch04_test_sequential>>
<<ch04_test_when>>
<<ch04_test_branch>>
<<ch04_test_repeat>>
<<ch04_test_fork>>
<<ch04_test_compress>>
<<ch04_test_custom>>