Skip to content

Composition Loop

skobeltsyn edited this page Mar 28, 2026 · 1 revision

Composition: Loop (.loop{})

Overview

A Loop runs an agent (or pipeline) repeatedly, feeding each output back as the next input. A feedback function decides whether to continue: return a non-null value to loop again, or null to stop and return the last result.

agent.loop { result -> if (done(result)) null else nextInput(result) }

The loop is not just syntactic sugar for while. It is a first-class composition primitive that can be embedded inside Pipelines with then, just like any other composition block.

Type Signature

class Loop<IN, OUT>(
    private val execution: (IN) -> OUT,
    private val next: (OUT) -> IN?,
)

// Create a Loop from an Agent
fun <A, B> Agent<A, B>.loop(next: (B) -> A?): Loop<A, B>

// Create a Loop from a Pipeline
fun <A, B> Pipeline<A, B>.loop(next: (B) -> A?): Loop<A, B>

The next function receives the agent's output (B) and must return either:

  • A? (non-null) -- the next input for another iteration
  • null -- stop looping and return the current output

Note that IN and OUT can be different types. The next function is responsible for transforming the output back into the input type when continuing.

Basic Example

Double a number until it exceeds 100:

import agents_engine.core.*
import agents_engine.composition.loop.loop

val double = agent<Int, Int>("double") {
    skills {
        skill<Int, Int>("double") {
            implementedBy { it * 2 }
        }
    }
}

val loop = double.loop { result ->
    if (result > 100) null else result
}

val result = loop(1)
// Trace: 1 -> 2 -> 4 -> 8 -> 16 -> 32 -> 64 -> 128 (stop)
// result == 128

A loop that runs exactly once (the feedback function always returns null):

val loop = agent.loop { null }
val result = loop(input)
// Equivalent to: agent(input)

Execution Model

The Loop.invoke implementation is straightforward:

operator fun invoke(input: IN): OUT {
    var current = execution(input)
    while (true) {
        val feedback = next(current) ?: return current
        current = execution(feedback)
    }
}

The agent executes once with the initial input. Then the next function is called. If it returns non-null, that value becomes the next input and the agent executes again. This continues until next returns null.

Loop on Pipelines

You can loop over an entire pipeline, not just a single agent. The pipeline runs as a unit on each iteration:

import agents_engine.composition.pipeline.then

val add1a = agent<Int, Int>("add1a") {
    skills { skill<Int, Int>("add1a") { implementedBy { it + 1 } } }
}
val add1b = agent<Int, Int>("add1b") {
    skills { skill<Int, Int>("add1b") { implementedBy { it + 1 } } }
}

val pipeline = add1a then add1b  // adds 2 per iteration

val loop = pipeline.loop { result ->
    if (result >= 10) null else result
}

val result = loop(0)
// Trace: 0 -> 2 -> 4 -> 6 -> 8 -> 10 (stop)
// result == 10

Composing Loops in Pipelines

A Loop is a composition primitive. You connect it to other stages using then:

val prepare  = agent<String, Int>("len")  {
    skills { skill<String, Int>("len") { implementedBy { it.length } } }
}
val process  = agent<Int, Int>("inc") {
    skills { skill<Int, Int>("inc") { implementedBy { it + 1 } } }
}
val finalize = agent<Int, String>("wrap") {
    skills { skill<Int, String>("wrap") { implementedBy { "result:$it" } } }
}

val loop = process.loop { result -> if (result >= 5) null else result }

val pipeline = prepare then loop then finalize
val result = pipeline("hi")
// "hi".length = 2
// loop: 2 -> 3 -> 4 -> 5 (stop)
// finalize: "result:5"

Loops can appear at any position in a pipeline:

// At the beginning
val pipeline = loop then postprocess

// In the middle
val pipeline = prepare then loop then finalize

// At the end
val pipeline = prepare then loop

Quality Gate Pattern

A common use case is iterating until a quality threshold is met. The next function can invoke other agents or run arbitrary Kotlin logic:

val checker = agent<Int, Boolean>("checker") {
    skills { skill<Int, Boolean>("checker") { implementedBy { it < 10 } } }
}
val body = agent<Int, Int>("body") {
    skills { skill<Int, Int>("body") { implementedBy { it * 2 } } }
}

val loop = body.loop { result ->
    if (checker(result)) result else null  // checker is just a function call
}

val result = loop(1)
// 1 -> 2 -> 4 -> 8 -> 16 (checker returns false, stop)
// result == 16

Different Input and Output Types

The next function transforms the output type back to the input type, so loops work even when IN != OUT:

data class Raw(val text: String)
data class Processed(val words: List<String>, val needsMore: Boolean)

val process = agent<Raw, Processed>("process") {
    skills { skill<Raw, Processed>("process") { implementedBy { raw ->
        val words = raw.text.split(" ")
        Processed(words, words.size < 4)
    } } }
}

val loop = process.loop { result ->
    if (!result.needsMore) null
    else Raw(result.words.joinToString(" ") + " extra")  // transform back to Raw
}

val result = loop(Raw("a b"))
// "a b" -> [a, b] needsMore=true -> "a b extra" -> [a, b, extra] needsMore=true
// -> "a b extra extra" -> [a, b, extra, extra] needsMore=false (stop)

Retry Pattern

Model retries by returning a new input when the result is not acceptable:

var attempts = 0
val attempt = agent<String, Int>("attempt") {
    skills { skill<String, Int>("attempt") { implementedBy { _ -> ++attempts } } }
}

val loop = attempt.loop { result ->
    if (result >= 3) null else "retry"
}

val result = loop("start")
// result == 3, attempts == 3

See also: While Loops for imperative iteration | Pipeline | Parallel | Branch | Forum

Clone this wiki locally