-
Notifications
You must be signed in to change notification settings - Fork 0
Composition Loop
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.
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.
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 == 128A loop that runs exactly once (the feedback function always returns null):
val loop = agent.loop { null }
val result = loop(input)
// Equivalent to: agent(input)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.
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 == 10A 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 loopA 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 == 16The 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)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 == 3See also: While Loops for imperative iteration | Pipeline | Parallel | Branch | Forum
Getting Started
Core Concepts
Composition Operators
LLM Integration
- Model & Tool Calling
- Tool Error Recovery
- Skill Selection & Routing
- Budget Controls
- Observability Hooks
Guided Generation
Agent Memory
Reference