Skip to content

Single Placement Rule

skobeltsyn edited this page Mar 28, 2026 · 1 revision

The 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.


What It Is

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 placed

The rule is per-instance, not per-configuration. Two agents with identical configuration are two separate instances and can be placed independently.


Why It Exists

Preventing Shared Mutable State

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.

Avoiding Ambiguous Execution

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.

Making Composition Graphs Acyclic

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.


How It Works

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
}
  • placedIn starts as null (unplaced).
  • The first call to markPlaced("pipeline") sets it to "pipeline".
  • Any subsequent call triggers the require check 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.


Cross-Structure Enforcement

Every composition operator calls markPlaced(). This means the rule is enforced uniformly across all structure types:

Pipeline (then)

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)) }
}

Parallel (/)

operator fun <A, B> Agent<A, B>.div(other: Agent<A, B>): Parallel<A, B> {
    this.markPlaced("parallel")
    other.markPlaced("parallel")
    return Parallel(...)
}

Forum (*)

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))
}

Loop (.loop { })

fun <A, B> Agent<A, B>.loop(next: (B) -> A?): Loop<A, B> {
    this.markPlaced("loop")
    return Loop(execution = { input -> this(input) }, next = next)
}

Branch (.branch { })

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)) }
    }
}

Cross-Type Violations

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.


The Fix: Factory Functions

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 instance

For 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")

Code Examples

What Fails

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 placed

Pipeline 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!

How to Fix It

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 instances

Parameterized 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]" }

Full Example: Shared Logic, Independent Instances

// 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.

Clone this wiki locally