diff --git a/README.md b/README.md index a89c84e..9c866fc 100644 --- a/README.md +++ b/README.md @@ -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 ""` 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). diff --git a/build.gradle.kts b/build.gradle.kts index 1323cdf..215ee1b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -203,6 +203,91 @@ tasks.register("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 = 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(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("copySwarmDemoLibs") { + description = "Stage framework + runtime libs next to the swarm demo JARs" + group = "build" + dependsOn("jar") // produces build/libs/agents-kt-.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("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() diff --git a/docs/prd.md b/docs/prd.md index 08fd3b2..f7d37bb 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -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 ""` 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)* diff --git a/src/main/kotlin/agents_engine/runtime/Swarm.kt b/src/main/kotlin/agents_engine/runtime/Swarm.kt new file mode 100644 index 0000000..9285d01 --- /dev/null +++ b/src/main/kotlin/agents_engine/runtime/Swarm.kt @@ -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` + * 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> = + 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> = + 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` + * 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 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 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 + asString.invoke(query)?.toString() ?: "null" + } + registerBuiltInTool(tool) + enableAutoTool(tool.name) +} diff --git a/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt new file mode 100644 index 0000000..01a684f --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/SwarmJarIntegrationTest.kt @@ -0,0 +1,305 @@ +package agents_engine.runtime + +import java.io.File +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest +import javax.tools.JavaCompiler +import javax.tools.StandardLocation +import javax.tools.ToolProvider +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end integration for #984 — really compiles, packages, and classloads + * sibling JARs, then runs `Swarm.discover` against them. + * + * Two scenarios: + * - single-JAR — basic round-trip + * - multi-JAR (the actual swarm UX) — drop two independently-compiled JARs + * into one folder, point a `URLClassLoader` at all of them, verify both + * are discovered, both invoke, and a captain can absorb both + * + * Uses `javax.tools.JavaCompiler` (always shipped with the JDK), so no extra + * Gradle dependency. Provider sources are intentionally Java (not Kotlin) so + * we don't drag the Kotlin compiler into the test JVM. + */ +@OptIn(kotlin.io.path.ExperimentalPathApi::class) +class SwarmJarIntegrationTest { + + @Test + fun `compile, JAR-package, classload, and discover one sibling`() { + val workDir = createTempDirectory("swarm-jar-it-single-") + try { + val jar = compileSiblingJar( + workDir = workDir, + javaPackage = "external", + providerSimpleName = "ExternalAgentProvider", + agentName = "jar-sibling", + jarFileName = "external-agent.jar", + ) + val loader = URLClassLoader(arrayOf(jar.toUri().toURL()), this::class.java.classLoader) + val discovered = Swarm.discover(loader) + + val names = discovered.map { it.name } + assertTrue("jar-sibling" in names, "expected the JAR-loaded sibling; got: $names") + + val sibling = discovered.single { it.name == "jar-sibling" } + @Suppress("UNCHECKED_CAST") + val asString = sibling as agents_engine.core.Agent + assertEquals("from-jar:hello", asString.invoke("hello")) + + val captain = agents_engine.core.agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + val tool = captain.toolMap["jar-sibling"]!! + assertEquals("from-jar:world", tool.executor(mapOf("query" to "world")).toString()) + } finally { + workDir.deleteRecursively() + } + } + + @Test + fun `multi-JAR — drop several jars into a folder and discover them all`() { + // The original swarm UX: many separate agent JARs sit in one folder, + // a captain process points its classloader at all of them. Each JAR + // brings its own provider. ServiceLoader iterates every entry. + val workDir = createTempDirectory("swarm-jar-it-multi-") + val jarFolder = workDir.resolve("agents").apply { createDirectories() } + try { + // Each "agent" is a separately-compiled JAR with its own + // package and provider class — proving they don't share build + // artifacts and could realistically come from different teams. + val alphaJar = compileSiblingJar( + workDir = workDir.resolve("alpha-build").apply { createDirectories() }, + javaPackage = "swarm.alpha", + providerSimpleName = "AlphaProvider", + agentName = "alpha-jar-agent", + jarFileName = "alpha-agent.jar", + outputDir = jarFolder, + ) + val betaJar = compileSiblingJar( + workDir = workDir.resolve("beta-build").apply { createDirectories() }, + javaPackage = "swarm.beta", + providerSimpleName = "BetaProvider", + agentName = "beta-jar-agent", + jarFileName = "beta-agent.jar", + outputDir = jarFolder, + ) + + // Point a single URLClassLoader at both JARs from the folder — + // exactly what a captain main() would do on startup if it + // shell-globbed the folder, or what a launch script does with + // `java -cp 'agents/*'`. + val loader = URLClassLoader( + arrayOf(alphaJar.toUri().toURL(), betaJar.toUri().toURL()), + this::class.java.classLoader, + ) + + val discovered = Swarm.discover(loader) + val names = discovered.map { it.name }.toSet() + + // The URLClassLoader's parent is the test classloader, which has + // the in-test fixture provider registered. So discovery yields + // both JAR siblings PLUS that fixture. We assert only that the + // JAR siblings are present — the multi-JAR scenario is what's + // under test, not classloader hierarchy details. + assertTrue( + "alpha-jar-agent" in names, + "alpha JAR should be discovered; got: $names", + ) + assertTrue( + "beta-jar-agent" in names, + "beta JAR should be discovered; got: $names", + ) + + // Each sibling really runs — the JAR boundary is fully traversable. + val alpha = discovered.single { it.name == "alpha-jar-agent" } + val beta = discovered.single { it.name == "beta-jar-agent" } + + @Suppress("UNCHECKED_CAST") + val alphaStr = alpha as agents_engine.core.Agent + + @Suppress("UNCHECKED_CAST") + val betaStr = beta as agents_engine.core.Agent + + assertEquals("from-jar:ping", alphaStr.invoke("ping")) + assertEquals("from-jar:pong", betaStr.invoke("pong")) + + // The captain absorbs both — each becomes a tool, each callable + // independently. This is the full swarm UX. + val captain = agents_engine.core.agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(alpha) + captain.absorb(beta) + + assertTrue("alpha-jar-agent" in captain.toolMap) + assertTrue("beta-jar-agent" in captain.toolMap) + + val alphaTool = captain.toolMap["alpha-jar-agent"]!! + val betaTool = captain.toolMap["beta-jar-agent"]!! + assertEquals( + "from-jar:fromAlpha", + alphaTool.executor(mapOf("query" to "fromAlpha")).toString(), + ) + assertEquals( + "from-jar:fromBeta", + betaTool.executor(mapOf("query" to "fromBeta")).toString(), + ) + } finally { + workDir.deleteRecursively() + } + } + + @Test + fun `multi-JAR — captain glob-loads every jar in a folder`() { + // Variant: simulate the production UX more closely by glob-loading + // every *.jar in the folder rather than passing each path explicitly. + // The captain doesn't need to know how many siblings there are or + // what they're called — drop a JAR into the folder and it joins. + val workDir = createTempDirectory("swarm-jar-it-glob-") + val jarFolder = workDir.resolve("agents").apply { createDirectories() } + try { + compileSiblingJar( + workDir = workDir.resolve("a"), javaPackage = "swarm.a", + providerSimpleName = "AProvider", agentName = "agent-a", + jarFileName = "a.jar", outputDir = jarFolder, + ) + compileSiblingJar( + workDir = workDir.resolve("b"), javaPackage = "swarm.b", + providerSimpleName = "BProvider", agentName = "agent-b", + jarFileName = "b.jar", outputDir = jarFolder, + ) + compileSiblingJar( + workDir = workDir.resolve("c"), javaPackage = "swarm.c", + providerSimpleName = "CProvider", agentName = "agent-c", + jarFileName = "c.jar", outputDir = jarFolder, + ) + + // Glob the folder — what a launch script would do. + val urls = Files.list(jarFolder).use { stream -> + stream + .filter { it.toString().endsWith(".jar") } + .map { it.toUri().toURL() } + .toList() + }.toTypedArray() + assertEquals(3, urls.size, "should have globbed three JARs") + + val loader = URLClassLoader(urls, this::class.java.classLoader) + val discovered = Swarm.discover(loader) + val names = discovered.map { it.name }.toSet() + + // Assert all three JAR-supplied agents are present — the parent + // classloader's in-test fixture may also appear; we don't care + // about it for this assertion. + assertTrue("agent-a" in names, "missing agent-a; got: $names") + assertTrue("agent-b" in names, "missing agent-b; got: $names") + assertTrue("agent-c" in names, "missing agent-c; got: $names") + } finally { + workDir.deleteRecursively() + } + } + + /** + * Compile a Java AgentProvider source, write its META-INF/services + * descriptor, and pack into a JAR file. Returns the JAR path. + * + * Each call is hermetic — its own work dir, its own package, its own + * provider class — so multiple invocations produce independent JARs. + */ + private fun compileSiblingJar( + workDir: Path, + javaPackage: String, + providerSimpleName: String, + agentName: String, + jarFileName: String, + outputDir: Path = workDir, + ): Path { + Files.createDirectories(workDir) + + val src = workDir.resolve("$providerSimpleName.java") + src.writeText( + """ + package $javaPackage; + import agents_engine.runtime.AgentProvider; + import agents_engine.core.Agent; + public class $providerSimpleName implements AgentProvider { + @Override + public Agent build() { + return agents_engine.runtime.SwarmJarFixtureBridge.makeAgent("$agentName"); + } + } + """.trimIndent() + ) + + val classesDir = workDir.resolve("classes").apply { createDirectories() } + val compiler: JavaCompiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { + "no JDK Java compiler — running on a JRE? need a JDK" + } + compiler.getStandardFileManager(null, null, Charsets.UTF_8).use { fm -> + fm.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, listOf(classesDir)) + fm.setLocation( + StandardLocation.CLASS_PATH, + System.getProperty("java.class.path").split(File.pathSeparator).map { File(it) }, + ) + val units = fm.getJavaFileObjectsFromPaths(listOf(src)) + val task = compiler.getTask(null, fm, null, null, null, units) + val ok = task.call() ?: false + assertTrue(ok, "Java compilation must succeed for $providerSimpleName") + } + + val servicesDir = classesDir.resolve("META-INF/services").apply { createDirectories() } + servicesDir.resolve("agents_engine.runtime.AgentProvider") + .writeText("$javaPackage.$providerSimpleName\n") + + val jarPath = outputDir.resolve(jarFileName) + Files.createDirectories(outputDir) + packJar(classesDir, jarPath) + return jarPath + } + + private fun packJar(classesDir: Path, jarPath: Path) { + val manifest = Manifest().apply { mainAttributes.putValue("Manifest-Version", "1.0") } + Files.newOutputStream(jarPath).use { fos -> + JarOutputStream(fos, manifest).use { jar -> + Files.walk(classesDir).use { paths -> + for (path in paths) { + if (Files.isDirectory(path)) continue + val rel = classesDir.relativize(path).toString().replace(File.separatorChar, '/') + jar.putNextEntry(JarEntry(rel)) + Files.newInputStream(path).use { it.copyTo(jar) } + jar.closeEntry() + } + } + } + } + } +} + +/** + * Bridge for [SwarmJarIntegrationTest] — the JAR-compiled Java source calls + * here so the test source itself stays small and Kotlin stays in the test + * sources (not inside the dynamically compiled JARs). The same bridge is + * shared by all on-the-fly compiled JARs in the multi-JAR tests. + */ +internal object SwarmJarFixtureBridge { + @JvmStatic + fun makeAgent(name: String): agents_engine.core.Agent<*, *> = + agents_engine.core.agent(name) { + skills { + skill("op", "op") { + implementedBy { "from-jar:$it" } + } + } + } +} diff --git a/src/test/kotlin/agents_engine/runtime/SwarmTest.kt b/src/test/kotlin/agents_engine/runtime/SwarmTest.kt new file mode 100644 index 0000000..33fd04f --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/SwarmTest.kt @@ -0,0 +1,148 @@ +package agents_engine.runtime + +import agents_engine.core.agent +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +// Tests for #984 — Swarm: ServiceLoader-based agent discovery + absorb. +// +// Unit tests exercise the AgentProvider/Swarm/absorb mechanics with fixtures +// declared in this module's test sources. The JAR-compilation integration +// test lives in SwarmJarIntegrationTest. +class SwarmTest { + + private fun namedAgent(name: String, transform: (String) -> String = { "OUT:$it" }) = + agent(name) { + skills { skill("op", "op") { implementedBy(transform) } } + } + + @Test + fun `AgentProvider is a real interface usable as a ServiceLoader contract`() { + // Ensure the type exists, has the right shape, and is loadable. + val cls = AgentProvider::class.java + assertTrue(cls.isInterface, "AgentProvider must be an interface") + val build = cls.getDeclaredMethod("build") + assertNotNull(build) + } + + @Test + fun `Swarm discover with explicit classloader uses that classloader's services`() { + // We construct a classloader that has NO AgentProvider services registered. + // Even if the parent classloader has some (from test fixtures), the + // discovery method must respect the supplied loader. + val emptyLoader = java.net.URLClassLoader(emptyArray(), null) + val results = Swarm.discover(emptyLoader) + assertEquals(emptyList(), results, "isolated classloader should yield no providers") + } + + @Test + fun `Swarm discover finds the in-test fixture provider on the default classloader`() { + // SwarmTestProviderFixture is declared below + registered via + // src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider. + val results = Swarm.discover() + val names = results.map { it.name } + assertTrue( + "swarm-fixture-alpha" in names, + "expected fixture agent in discovery; got: $names", + ) + } + + @Test + fun `absorb adds a tool with the sibling's name`() { + val sibling = namedAgent("helper") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "captain-out" } } } + } + captain.absorb(sibling) + assertTrue( + "helper" in captain.toolMap, + "absorbed sibling should appear as a tool: ${captain.toolMap.keys}", + ) + } + + @Test + fun `absorbed tool delegates to the sibling and returns its output`() { + val sibling = namedAgent("helper") { "HELPER-SAW:$it" } + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + + // Invoke the absorbed tool directly via the tool map. + val tool = captain.toolMap["helper"]!! + val result = tool.executor(mapOf("query" to "hello")) + assertEquals("HELPER-SAW:hello", result.toString()) + } + + @Test + fun `absorbing two siblings with the same name fails fast`() { + val a = namedAgent("dup") + val b = namedAgent("dup") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(a) + assertThrows { captain.absorb(b) } + } + + @Test + fun `absorb is a no-op when sibling name collides with the captain's name (skip self)`() { + // If user accidentally tries to absorb their own captain into itself — + // collision against the agent's own name. Should error rather than + // create a recursive tool. + val captain = agent("self") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + // Trying to absorb captain into itself. + assertThrows { captain.absorb(captain) } + } + + @Test + fun `absorb of a non-String input sibling fails fast with a helpful error`() { + @Suppress("unused") + data class TypedInput(val v: String) + + val sibling = agent("typed") { + skills { skill("op", "op") { implementedBy { "ok" } } } + } + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + val ex = assertThrows { captain.absorb(sibling) } + assertTrue( + ex.message.orEmpty().contains("String", ignoreCase = true), + "error should mention String input requirement: ${ex.message}", + ) + } + + @Test + fun `absorbed tool is auto-available across the captain's skills`() { + // Auto-tool means the captain can call the absorbed sibling from any + // of its skills without listing it explicitly in tools(...). + val sibling = namedAgent("worker") + val captain = agent("captain") { + skills { skill("op", "op") { implementedBy { "x" } } } + } + captain.absorb(sibling) + // The framework's existing autoToolNames mechanism is internal but + // we can probe it via reflection of the public toolMap presence + // PLUS knowing that absorb wires it up. Verifying via the toolMap is + // sufficient — any further integration is through the agentic loop. + assertTrue("worker" in captain.toolMap) + } +} + +/** + * In-test AgentProvider fixture for [SwarmTest.discover finds the in-test + * fixture provider]. Registered via + * src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider. + */ +class SwarmTestProviderFixture : AgentProvider { + override fun build(): agents_engine.core.Agent<*, *> = + agent("swarm-fixture-alpha") { + skills { skill("op", "op") { implementedBy { "fixture:$it" } } } + } +} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt new file mode 100644 index 0000000..fb9ef75 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/exitagent/ExitAgent.kt @@ -0,0 +1,44 @@ +package agents_engine.runtime.swarmdemo.exitagent + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider +import kotlin.system.exitProcess + +// exit.jar — sibling agent for the swarm demo (#984). Self-contained. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +fun buildExitAgent(): Agent = agent("exit") { + prompt(""" + You have exactly ONE tool: `exit_app`. Call it with no arguments + when the user wants to close, exit, quit, leave, or end the + session. Do NOT call it for other requests. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("exit_app", "Close the application. Use when the user asks to exit, quit, or end.") { _ -> + println() + println("(exit agent — shutting down)") + exitProcess(0) + } + } + skills { + skill("close", "End the session on user request") { + tools("exit_app") + } + } + onToolUse { name, args, result -> traceTool("exit", name, args, result) } +} + +class ExitProvider : AgentProvider { + override fun build(): Agent<*, *> = buildExitAgent() +} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt new file mode 100644 index 0000000..9b61a20 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/factor/FactorAgent.kt @@ -0,0 +1,66 @@ +package agents_engine.runtime.swarmdemo.factor + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider + +// factor.jar — sibling agent for the swarm demo (#984). Self-contained: +// own copies of helpers so the JAR doesn't depend on any sibling's classes. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +internal fun readInt(args: Map, key: String): Int { + val v = args[key] ?: args.values.firstOrNull() + ?: error("missing argument: $key") + return when (v) { + is Number -> v.toInt() + is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") + else -> error("'$v' is not an integer") + } +} + +internal fun primeFactors(nIn: Int): List { + var n = nIn + val result = mutableListOf() + var p = 2 + while (p.toLong() * p.toLong() <= n) { + while (n % p == 0) { result += p; n /= p } + p++ + } + if (n > 1) result += n + return result +} + +fun buildFactorAgent(): Agent = agent("factor") { + prompt(""" + You factor integers into prime factors. + Use the `factor_number` tool with argument n (integer ≥ 2). + Reply with the comma-separated prime factors, nothing else. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("factor_number", "Compute the prime factors of n. Argument: n (integer ≥ 2).") { args -> + val n = readInt(args, "n") + require(n >= 2) { "n must be ≥ 2 to factor" } + primeFactors(n).joinToString(", ") + } + } + skills { + skill("factor", "Prime-factor an integer") { + tools("factor_number") + } + } + onToolUse { name, args, result -> traceTool("factor", name, args, result) } +} + +class FactorProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFactorAgent() +} diff --git a/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt b/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt new file mode 100644 index 0000000..49f59a6 --- /dev/null +++ b/src/test/kotlin/agents_engine/runtime/swarmdemo/fib/FibAgent.kt @@ -0,0 +1,114 @@ +package agents_engine.runtime.swarmdemo.fib + +import agents_engine.core.Agent +import agents_engine.core.agent +import agents_engine.runtime.AgentProvider +import agents_engine.runtime.LiveRunner +import agents_engine.runtime.Swarm +import agents_engine.runtime.absorb +import kotlin.system.exitProcess + +// Captain JAR for the swarm demo (#984). Lives in build/tmp/jars_swarm_demo/fib.jar. +// Self-contained: own copies of small helpers so the JAR doesn't need to +// pull in shared "common" classes from anywhere else. + +private const val MODEL = "gpt-oss:120b-cloud" +private const val HOST = "localhost" +private const val PORT = 11434 + +internal fun traceTool(agentName: String, toolName: String, args: Map, result: Any?) { + val argsStr = if (args.isEmpty()) "" else args.entries.joinToString(", ") { "${it.key}=${it.value}" } + val resultStr = result?.toString()?.let { if (it.length > 80) it.take(77) + "..." else it } ?: "null" + System.err.println(" [$agentName] $toolName($argsStr) → $resultStr") +} + +internal fun readInt(args: Map, key: String): Int { + val v = args[key] ?: args.values.firstOrNull() + ?: error("missing argument: $key") + return when (v) { + is Number -> v.toInt() + is String -> v.trim().toIntOrNull() ?: error("'$v' is not an integer") + else -> error("'$v' is not an integer") + } +} + +internal fun fib(n: Int): Long { + require(n >= 0) { "n must be ≥ 0" } + var a = 0L; var b = 1L + repeat(n) { val t = a + b; a = b; b = t } + return a +} + +fun buildFibAgent(): Agent = agent("fib") { + prompt(""" + You are a router-style assistant. Inspect the user's request + and pick the right tool; do NOT answer in plain text when a + tool is appropriate. + + Your tools: + - `fibonacci` — compute the nth Fibonacci. Use for any "fib(n)" + or "Fibonacci" request. + - `factor` — delegate to the factor sibling. Use whenever the + user says "factor", "prime factors", or asks to break a number + down into its prime factorization. Call it with the user's + original request as the `query` argument. + - `exit` — delegate to the exit sibling. Use whenever the user + says exit / quit / leave / close / bye / goodbye / "I'm done". + Call it with the user's original request as the `query` + argument. NEVER reply with "session closed" in plain text; + ALWAYS call the `exit` tool. + + After a tool returns, render the result in 1–2 short sentences. + """.trimIndent()) + model { ollama(MODEL); host = HOST; port = PORT; temperature = 0.0 } + tools { + tool("fibonacci", "Compute the nth Fibonacci number. Argument: n (integer ≥ 0).") { args -> + val n = readInt(args, "n") + fib(n).toString() + } + } + skills { + skill("compute", "Run a math calculation, possibly via tools") { + tools("fibonacci") + } + } + onToolUse { name, args, result -> traceTool("fib", name, args, result) } +} + +class FibProvider : AgentProvider { + override fun build(): Agent<*, *> = buildFibAgent() +} + +// Captain main — packaged into fib.jar. The swarmDemo Gradle task launches +// this with classpath = framework + fib.jar + factor.jar + exit.jar; the +// classpath does NOT include the test source classes, so ServiceLoader +// finds providers only from the three JARs. +fun main(args: Array) { + val members = Swarm.discover() + val me = members.singleOrNull { it.name == "fib" } + ?: error("captain 'fib' not found among discovered providers: ${members.map { it.name }}") + val siblings = members.filter { it.name != me.name } + + println() + println("============================================================") + println("Swarm demo (#984) — captain: ${me.name}") + println("Discovered ${siblings.size} siblings: ${siblings.joinToString { it.name }}") + println("Each sibling carries its own personality (prompt + tools);") + println("the captain absorbed them as tools and dispatches via its LLM.") + println() + println("Try:") + println(" fib> what's fib(10)?") + println(" fib> factor 84") + println(" fib> bye, please exit the app") + println("============================================================") + println() + + siblings.forEach { sibling -> me.absorb(sibling) } + + @Suppress("UNCHECKED_CAST") + val captain = me as Agent + val rc = LiveRunner.serve(captain, args) { + prompt = "fib> " + } + exitProcess(rc) +} diff --git a/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..5de495d --- /dev/null +++ b/src/test/resources/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.SwarmTestProviderFixture diff --git a/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..5e25f4d --- /dev/null +++ b/src/test/swarm-jar-resources/exit/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.exitagent.ExitProvider diff --git a/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..665416d --- /dev/null +++ b/src/test/swarm-jar-resources/factor/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.factor.FactorProvider diff --git a/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider b/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider new file mode 100644 index 0000000..f8c91e7 --- /dev/null +++ b/src/test/swarm-jar-resources/fib/META-INF/services/agents_engine.runtime.AgentProvider @@ -0,0 +1 @@ +agents_engine.runtime.swarmdemo.fib.FibProvider