-
Notifications
You must be signed in to change notification settings - Fork 0
Single Placement Rule
The single-placement rule is a structural constraint enforced at composition time: each Agent instance can participate in exactly one composition. This page explains what the rule is, why it exists, how it works internally, and how to work with it in practice.
When you place an agent into a pipeline, parallel group, forum, loop, or branch, that agent instance is permanently claimed by that structure. Any attempt to place it into a second structure throws IllegalArgumentException.
val a = agent<A, B>("a") { /* ... */ }
val b = agent<B, C>("b") { /* ... */ }
val c = agent<B, C>("c") { /* ... */ }
a then b // OK: a is now placed in "pipeline"
a then c // THROWS: a is already placedThe rule is per-instance, not per-configuration. Two agents with identical configuration are two separate instances and can be placed independently.
An Agent holds mutable state: its skill map, tool map, memory bank, event listeners, and model configuration. If the same instance were shared across two pipelines executing concurrently, they would race on this state. The single-placement rule eliminates this class of bugs by making sharing impossible.
Consider an agent placed in both a pipeline and a parallel group. When the agent runs, which context applies? Does the pipeline's sequential ordering take precedence, or the parallel group's concurrent dispatch? The answer is undefined. Single placement removes the ambiguity.
Allowing the same agent instance in multiple positions could create cycles in the execution graph (e.g., an agent feeding back into itself through two different structures). The single-placement rule guarantees that each instance appears exactly once, making the composition graph a tree.
The mechanism is three lines of code in Agent:
private var placedIn: String? = null
fun markPlaced(context: String) {
require(placedIn == null) {
"Agent \"$name\" is already placed in $placedIn. " +
"Each agent instance can only participate once. " +
"Create a new instance for \"$context\"."
}
placedIn = context
}-
placedInstarts asnull(unplaced). - The first call to
markPlaced("pipeline")sets it to"pipeline". - Any subsequent call triggers the
requirecheck and throws.
The context string is the name of the structure type -- "pipeline", "parallel", "forum", "loop", or "branch" -- and appears in the error message to help you diagnose the problem.
Every composition operator calls markPlaced(). This means the rule is enforced uniformly across all structure types:
infix fun <A, B, C> Agent<A, B>.then(other: Agent<B, C>): Pipeline<A, C> {
this.markPlaced("pipeline")
other.markPlaced("pipeline")
return Pipeline(listOf(this, other)) { input -> other(this(input)) }
}operator fun <A, B> Agent<A, B>.div(other: Agent<A, B>): Parallel<A, B> {
this.markPlaced("parallel")
other.markPlaced("parallel")
return Parallel(...)
}operator fun <A, B, C> Agent<A, B>.times(other: Agent<*, C>): Forum<A, C> {
this.markPlaced("forum")
other.markPlaced("forum")
return Forum(listOf(this, other))
}fun <A, B> Agent<A, B>.loop(next: (B) -> A?): Loop<A, B> {
this.markPlaced("loop")
return Loop(execution = { input -> this(input) }, next = next)
}The source agent is not re-marked (it is the caller), but each handler agent is marked when you write on<T>() then agent:
inner class OnClause<T : Any>(...) {
infix fun then(agent: Agent<T, OUT>) {
agent.markPlaced("branch")
routes[klass] = { input -> agent(castFn(input)) }
}
}Because all operators call the same markPlaced() on the same Agent instance, cross-type violations are caught too:
val a = agent<A, B>("a") { /* ... */ }
val b = agent<B, C>("b") { /* ... */ }
val c = agent<A, C>("c") { /* ... */ }
a then b // a is placed in "pipeline"
a * c // THROWS: Agent "a" is already placed in pipeline.
// Each agent instance can only participate once.
// Create a new instance for "forum".This works across all combinations: pipeline + forum, parallel + loop, loop + pipeline, and so on.
When you need the same agent logic in multiple compositions, use a factory function that creates a new instance each time:
fun createUppercaser(): Agent<String, String> =
agent<String, String>("uppercaser") {
skills {
skill<String, String>("upper", "Convert to uppercase") {
implementedBy { it.uppercase() }
}
}
}
// Each call returns a fresh instance
val pipeline1 = createUppercaser() then someAgent
val pipeline2 = createUppercaser() then otherAgent // OK: different instanceFor agentic agents with more complex configuration:
fun createCoder(model: String = "llama3"): Agent<String, String> =
agent<String, String>("coder") {
prompt("You are a Kotlin developer.")
model { ollama(model); temperature = 0.2 }
tools {
tool("compile", "Compile Kotlin code") { args ->
Runtime.getRuntime().exec("kotlinc ${args["file"]}").waitFor()
"compiled"
}
}
skills {
skill<String, String>("implement", "Implement a feature") {
tools("compile")
}
}
}
// Two independent coders in a parallel review setup
val parallel = createCoder() / createCoder("deepseek-coder")Same instance in two pipelines:
val a = agent<String, Int>("a") {
skills { skill<String, Int>("a") { implementedBy { it.length } } }
}
val b = agent<Int, String>("b") {
skills { skill<Int, String>("b") { implementedBy { "len=$it" } } }
}
val c = agent<Int, String>("c") {
skills { skill<Int, String>("c") { implementedBy { "count=$it" } } }
}
val p1 = a then b // a placed in "pipeline"
val p2 = a then c // IllegalArgumentException!Same instance appearing twice in one pipeline:
val a = agent<String, Int>("a") {
skills { skill<String, Int>("a") { implementedBy { it.length } } }
}
val b = agent<Int, String>("b") {
skills { skill<Int, String>("b") { implementedBy { it.toString() } } }
}
val p = a then b // a placed
val p2 = p then a // IllegalArgumentException: a is already placedPipeline agent reused in forum:
val a = agent<A, B>("a") { /* ... */ }
val b = agent<B, C>("b") { /* ... */ }
val c = agent<A, C>("c") { /* ... */ }
a then b // a placed in "pipeline"
a * c // IllegalArgumentException!Loop agent reused in parallel:
val a = agent<Int, Int>("a") {
skills { skill<Int, Int>("a") { implementedBy { it + 1 } } }
}
val b = agent<Int, Int>("b") {
skills { skill<Int, Int>("b") { implementedBy { it * 2 } } }
}
a.loop { if (it > 10) null else it } // a placed in "loop"
a / b // IllegalArgumentException!Factory function pattern:
fun makeIncrementer() = agent<Int, Int>("inc") {
skills { skill<Int, Int>("inc") { implementedBy { it + 1 } } }
}
val loop = makeIncrementer().loop { if (it > 10) null else it }
val parallel = makeIncrementer() / makeIncrementer() // OK: separate instancesParameterized factory for variants:
fun makeTransformer(
name: String,
transform: (String) -> String,
) = agent<String, String>(name) {
skills {
skill<String, String>(name, "Transform text") {
implementedBy(transform)
}
}
}
val pipeline1 = makeTransformer("upper") { it.uppercase() } then
makeTransformer("exclaim") { "$it!" }
val pipeline2 = makeTransformer("upper") { it.uppercase() } then
makeTransformer("wrap") { "[$it]" }// Factory for a code reviewer agent
fun codeReviewer(focus: String) = agent<String, String>("reviewer-$focus") {
prompt("You review code focusing on $focus.")
model { ollama("llama3"); temperature = 0.1 }
skills {
skill<String, String>("review", "Review code for $focus issues") {
tools()
knowledge("guidelines", "$focus review checklist") {
File("docs/checklists/$focus.md").readText()
}
}
}
}
// Three independent reviewers running in parallel
val review = codeReviewer("security") /
codeReviewer("performance") /
codeReviewer("style")
// Aggregator collects the three review strings
val aggregator = agent<List<String>, String>("aggregator") {
skills {
skill<List<String>, String>("merge", "Merge reviews") {
implementedBy { reviews -> reviews.joinToString("\n---\n") }
}
}
}
// Full pipeline: reviews in parallel, then aggregate
val pipeline = review then aggregator
val finalReview: String = pipeline("fun process(data: Any) { ... }")Each codeReviewer() call creates a fresh agent instance with its own placedIn = null, so the parallel composition succeeds. The aggregator is a separate instance placed once in the trailing pipeline.
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