Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes
- **MCP server** — `McpServer.from(agent)` exposes an agent as an MCP-conformant server with explicit `tools/listChanged: false` capability (#619).
- **`McpRunner` standalone** — picocli-style one-liner main for shipping agents as MCP services.
- **`LiveShow` / `LiveRunner`** — REPL deployment with string-concatenated conversation history. Six factory overloads (Agent, Pipeline, Forum, Parallel, Loop, Branch) for any String-input structure; `--once "<prompt>"` for non-interactive use; built-in `/quit`, `/clear`, `/help` slash commands; user-extensible (#981).
- **`Swarm` + `absorb`** — drop sibling agent JARs into a folder, the captain ServiceLoader-discovers them and absorbs each as a tool with full agent personality preserved (prompt, skills, knowledge, memory). In-JVM, no IPC, no static-typing-across-JARs limitation MCP-stdio would impose (#984).
- **Frozen-after-construction agents** — structural mutators (skills, tools, memory, model, budget, prompt, error handlers, routing) reject post-construction calls (#697, #708).
- **Encapsulated tool/skill maps** — `Agent.toolMap` and `Agent.skills` are read-only `Map` views; mutation only via DSL or framework-internal escape hatches (#659, #667).
- **`LlmProviderException`** — provider-boundary errors (auth, model-not-found, capability mismatch) surface distinctly from output-parse errors (#702).
Expand Down
85 changes: 85 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,91 @@ tasks.register<JavaExec>("interactivePipeline") {
standardInput = System.`in`
}

// #984 — full swarm demo. Three sibling agents (fib / factor / exit) live as
// SEPARATE JAR files in build/tmp/jars_swarm_demo/, each with its own
// META-INF/services descriptor. The captain main is packaged inside fib.jar.
// At runtime, ServiceLoader walks the JARs on the classpath and finds all
// three providers — the same path a production swarm uses when JARs are
// dropped into a folder.
val swarmDemoJarsDir: Provider<Directory> = layout.buildDirectory.dir("tmp/jars_swarm_demo")

// Helper to register one swarm sibling Jar task. Each task pulls only its
// own subpackage's compiled classes plus its per-JAR service descriptor;
// no cross-JAR class sharing.
fun registerSwarmDemoJar(
taskName: String,
jarFileName: String,
classSubpackage: String,
resourcesPath: String,
) = tasks.register<Jar>(taskName) {
description = "Pack swarm demo agent classes into $jarFileName"
group = "build"
dependsOn("compileTestKotlin")
archiveFileName.set(jarFileName)
destinationDirectory.set(swarmDemoJarsDir)
sourceSets.test.get().output.classesDirs.forEach { classesDir ->
from(classesDir) {
include("agents_engine/runtime/swarmdemo/$classSubpackage/**")
}
}
from(resourcesPath)
}

val jarSwarmFib = registerSwarmDemoJar(
taskName = "jarSwarmFib",
jarFileName = "fib.jar",
classSubpackage = "fib",
resourcesPath = "src/test/swarm-jar-resources/fib",
)
val jarSwarmFactor = registerSwarmDemoJar(
taskName = "jarSwarmFactor",
jarFileName = "factor.jar",
classSubpackage = "factor",
resourcesPath = "src/test/swarm-jar-resources/factor",
)
val jarSwarmExit = registerSwarmDemoJar(
taskName = "jarSwarmExit",
jarFileName = "exit.jar",
classSubpackage = "exitagent",
resourcesPath = "src/test/swarm-jar-resources/exit",
)

// Stage the framework JAR + every runtime dependency next to the demo
// JARs so the swarm demo is launchable with a pure `java -cp ...` command,
// no Gradle needed. Output goes to build/tmp/jars_swarm_demo_lib/.
tasks.register<Copy>("copySwarmDemoLibs") {
description = "Stage framework + runtime libs next to the swarm demo JARs"
group = "build"
dependsOn("jar") // produces build/libs/agents-kt-<version>.jar
from(tasks.named("jar"))
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("tmp/jars_swarm_demo_lib"))
}

// Aggregate task — builds all three demo JARs and stages their runtime deps.
tasks.register("buildSwarmDemoJars") {
description = "Build the three swarm demo JARs (and stage runtime libs) so the demo can be launched with bare `java`"
group = "build"
dependsOn(jarSwarmFib, jarSwarmFactor, jarSwarmExit, "copySwarmDemoLibs")
}

tasks.register<JavaExec>("swarmDemo") {
description = "Run the swarm demo: captain `fib.jar` absorbs `factor.jar` + `exit.jar` siblings"
group = "verification"
dependsOn("buildSwarmDemoJars")

// Classpath = framework runtime + the three sibling JARs ONLY. We
// deliberately do NOT include sourceSets.test.runtimeClasspath, so
// ServiceLoader finds providers exclusively from the JARs (proves the
// real "drop JARs into a folder" path, not the in-test shortcut).
classpath = files(
sourceSets.main.get().runtimeClasspath,
fileTree(swarmDemoJarsDir) { include("*.jar") },
)
mainClass.set("agents_engine.runtime.swarmdemo.fib.FibAgentKt")
standardInput = System.`in`
}

java {
withSourcesJar()
withJavadocJar()
Expand Down
1 change: 1 addition & 0 deletions docs/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -3938,6 +3938,7 @@ Notation: `[x]` shipped, `[ ]` planned. Mirrors the README's roadmap so contribu
- [x] Supply-chain hygiene — pinned Gradle wrapper, dependency-locking via `gradle.lockfile`, `gradle/verification-metadata.xml` SHA-256 verification, `updateVerificationMetadata` cross-platform Gradle task (#858, #872, #883)
- [x] `loadResource(path)` / `loadResourceOrNull(path)` — read agent system prompts from classpath resources; fail-fast at agent construction when path is missing; UTF-8 decoded; leading-slash normalized (#980)
- [x] `LiveShow` / `LiveRunner` — REPL deployment surface mirroring MCP's two-layer split (`LiveShow.from(x).start()` + `LiveRunner.serve(x, args)`). Six factory overloads cover `Agent` / `Pipeline` / `Forum` / `Parallel` / `Loop` / `Branch` (any String-input structure). String-concatenated conversation history with `--- user ---` / `--- assistant ---` delimiters and configurable cap. Built-in `/quit`, `/exit`, `/clear`, `/help` plus user-extensible `slash(name) { }`. `--once "<prompt>"` for non-interactive single-turn use. ANSI color theme, ASCII Agents.KT banner, in-place cat spinner, lifecycle hooks (`onTurnStart` / `onTurnEnd` / `onErrorReported`), `renderOutput` post-processor (#981, #983)
- [x] `Swarm` — ServiceLoader-based agent discovery: each sibling JAR ships a `META-INF/services/agents_engine.runtime.AgentProvider`; the captain calls `Swarm.discover()` and `me.absorb(sibling)` to expose each sibling's `Agent<*, *>` surface as a tool with full personality preserved (prompt, skills, knowledge, memory). In-JVM only (single-classloader); cross-language is MCP's job (#984)
- [ ] `>>` — security/education wrap

### Phase 2: Runtime + Distribution *(Q2 2026)*
Expand Down
106 changes: 106 additions & 0 deletions src/main/kotlin/agents_engine/runtime/Swarm.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package agents_engine.runtime

import agents_engine.core.Agent
import agents_engine.model.ToolDef
import java.util.ServiceLoader

/**
* Service-loadable contract for an agent shipped from a separate JAR (#984).
*
* Each agent JAR places `META-INF/services/agents_engine.runtime.AgentProvider`
* pointing at a class that implements this interface. A captain agent then
* calls [Swarm.discover] to find and build all sibling providers from the
* classpath, and [Agent.absorb] to expose each sibling as a tool on itself.
*
* In-JVM (not MCP-stdio) by design — preserves the full `Agent<IN, OUT>`
* surface (prompt, skills, knowledge, memory, observability hooks, error
* handlers) of every sibling. Trade-off: JVM-only, no process isolation. See
* the issue description for the rationale.
*/
fun interface AgentProvider {
fun build(): Agent<*, *>
}

/**
* Discovers sibling agents on the classpath via [ServiceLoader]. Every
* registered [AgentProvider] is instantiated; its `build()` is invoked once;
* results are returned as a list in classloader-iteration order.
*/
object Swarm {

/** Discover via the calling thread's context classloader (the typical case). */
fun discover(): List<Agent<*, *>> =
discover(Thread.currentThread().contextClassLoader ?: this::class.java.classLoader)

/**
* Discover via [classLoader]. Used by tests that wire a custom
* [URLClassLoader] over compiled-on-the-fly JARs, and by tooling that
* needs explicit control of which classloader's services are seen.
*/
fun discover(classLoader: ClassLoader): List<Agent<*, *>> =
ServiceLoader.load(AgentProvider::class.java, classLoader)
.iterator()
.asSequence()
.map { it.build() }
.toList()
}

/**
* Absorb a sibling agent into this captain — registers a tool named after
* `sibling.name` whose executor delegates to `sibling.invoke(...)`. The
* absorbed tool is auto-enabled across the captain's skills, so the
* captain's LLM can reach the sibling without any per-skill `tools(...)`
* declaration.
*
* Constraints:
* - sibling.name must not collide with any existing tool on the captain
* (or with the captain's own name — would mean absorbing self).
* - sibling must accept `String` input. Typed-input siblings (`Agent<X, Y>`
* where `X != String`) require schema-driven invocation; out of scope for
* v1. They throw [IllegalArgumentException] at absorb time.
*
* The tool input is `query: String`. The framework's existing tool-call path
* will pass it through to `sibling.invoke(query)` and return the sibling's
* output as the tool's result (rendered via `toString()`).
*/
fun Agent<*, *>.absorb(sibling: Agent<*, *>) {
require(sibling.name != this.name) {
"cannot absorb self: agent \"${this.name}\" cannot absorb itself"
}
require(sibling.name !in this.toolMap) {
"agent \"${this.name}\" already has a tool named \"${sibling.name}\". " +
"Two siblings with the same name? Pick unique agent names per JAR."
}
// Accept-string check. We can't reflect Kotlin generic type params from a
// built Agent, so we sample a known String input through the sibling's
// public type contract: every Agent<String, *> can have its first skill's
// inType inspected. Skills carry KClass<*> for inType.
val firstSkill = sibling.skills.values.firstOrNull()
?: throw IllegalArgumentException(
"sibling \"${sibling.name}\" has no skills — nothing to absorb",
)
require(firstSkill.inType == String::class) {
"sibling \"${sibling.name}\" expects ${firstSkill.inType.simpleName} input, " +
"but absorb only supports Agent<String, *> for v1. " +
"Consider exposing the typed agent via a String-input adapter."
}

val tool = ToolDef(
name = sibling.name,
description = buildString {
append("Delegate to the \"")
append(sibling.name)
append("\" agent. Skills: ")
append(sibling.skills.values.joinToString("; ") { "${it.name} — ${it.description}" })
},
) { args ->
val query = args["query"]?.toString()
?: args.values.firstOrNull()?.toString()
?: ""
@Suppress("UNCHECKED_CAST")
val asString = sibling as Agent<String, *>
asString.invoke(query)?.toString() ?: "null"
}
registerBuiltInTool(tool)
enableAutoTool(tool.name)
}
Loading
Loading